praetorian-cli 2.2.3__py3-none-any.whl → 2.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- praetorian_cli/handlers/add.py +42 -27
- praetorian_cli/handlers/agent.py +29 -0
- praetorian_cli/handlers/cli_decorators.py +1 -1
- praetorian_cli/handlers/delete.py +12 -0
- praetorian_cli/handlers/get.py +21 -2
- praetorian_cli/handlers/link.py +29 -1
- praetorian_cli/handlers/list.py +22 -0
- praetorian_cli/handlers/unlink.py +29 -1
- praetorian_cli/handlers/utils.py +59 -0
- praetorian_cli/sdk/chariot.py +2 -0
- praetorian_cli/sdk/entities/configurations.py +1 -1
- praetorian_cli/sdk/entities/definitions.py +11 -2
- praetorian_cli/sdk/entities/files.py +10 -5
- praetorian_cli/sdk/entities/search.py +6 -4
- praetorian_cli/sdk/entities/webpage.py +180 -0
- praetorian_cli/sdk/model/globals.py +3 -0
- praetorian_cli/sdk/model/query.py +7 -0
- praetorian_cli/sdk/test/test_asset.py +36 -0
- praetorian_cli/sdk/test/test_conversation.py +195 -0
- praetorian_cli/sdk/test/test_webpage.py +46 -0
- praetorian_cli/sdk/test/test_z_cli.py +55 -0
- praetorian_cli/sdk/test/utils.py +5 -0
- praetorian_cli/ui/conversation/__init__.py +3 -0
- praetorian_cli/ui/conversation/textual_chat.py +622 -0
- {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/METADATA +1 -1
- {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/RECORD +30 -25
- {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/WHEEL +0 -0
- {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/entry_points.txt +0 -0
- {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/licenses/LICENSE +0 -0
- {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from praetorian_cli.sdk.model.query import Query, Node, Filter, Relationship
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Webpage:
|
|
5
|
+
"""The methods in this class are to be accessed from sdk.webpage, where sdk
|
|
6
|
+
is an instance of Chariot."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, api):
|
|
9
|
+
self.api = api
|
|
10
|
+
|
|
11
|
+
def add(self, url, parent_key=None):
|
|
12
|
+
"""
|
|
13
|
+
Add a Webpage to the Chariot database.
|
|
14
|
+
|
|
15
|
+
WebPages represent individual pages or endpoints that can be optionally
|
|
16
|
+
associated with a parent WebApplication. The backend uses a WebpageRequest
|
|
17
|
+
structure with embedded webpage data and optional parent key.
|
|
18
|
+
|
|
19
|
+
:param url: The full URL of the page
|
|
20
|
+
:type url: str
|
|
21
|
+
:param parent_key: Optional key of the parent WebApplication
|
|
22
|
+
:type webapp_key: str or None
|
|
23
|
+
:return: The created WebPage object
|
|
24
|
+
:rtype: dict
|
|
25
|
+
:raises Exception: If the URL is invalid or the request fails
|
|
26
|
+
|
|
27
|
+
**Example Usage:**
|
|
28
|
+
>>> # Add a simple page without parent
|
|
29
|
+
>>> page = sdk.webpage.add("https://app.example.com/login")
|
|
30
|
+
|
|
31
|
+
>>> # Add a page with parent WebApplication
|
|
32
|
+
>>> page = sdk.webpage.add(
|
|
33
|
+
... url="https://app.example.com/admin",
|
|
34
|
+
... parent_key="#webapplication#https://app.example.com")
|
|
35
|
+
|
|
36
|
+
**WebPage Object Structure:**
|
|
37
|
+
The returned Webpage object contains:
|
|
38
|
+
- key: Webpage identifier in format #webpage#{url}
|
|
39
|
+
- url: Full URL of the page
|
|
40
|
+
- status: Current status
|
|
41
|
+
- parent: Parent WebApplication relationship (if applicable)
|
|
42
|
+
- created: Creation timestamp
|
|
43
|
+
"""
|
|
44
|
+
if not url:
|
|
45
|
+
raise Exception("URL is required for Webpage")
|
|
46
|
+
|
|
47
|
+
if parent_key and not parent_key.startswith('#webapplication#'):
|
|
48
|
+
raise Exception("Invalid WebApplication key format")
|
|
49
|
+
|
|
50
|
+
payload = {
|
|
51
|
+
'webpage': {
|
|
52
|
+
'url': url,
|
|
53
|
+
'status': 'A' # Active status
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if parent_key:
|
|
59
|
+
payload['parent_key'] = parent_key
|
|
60
|
+
|
|
61
|
+
return self.api.post('webpage', payload)
|
|
62
|
+
|
|
63
|
+
def get(self, key):
|
|
64
|
+
"""
|
|
65
|
+
Get details of a specific Webpage by its key.
|
|
66
|
+
|
|
67
|
+
:param key: The WebPage key identifier
|
|
68
|
+
:type key: str
|
|
69
|
+
:return: Webpage object with detailed information, or None if not found
|
|
70
|
+
:rtype: dict or None
|
|
71
|
+
|
|
72
|
+
**Example Usage:**
|
|
73
|
+
>>> # Get a specific Webpage
|
|
74
|
+
>>> page = sdk.webpage.get("webpage_key_123")
|
|
75
|
+
|
|
76
|
+
**Webpage Object Structure:**
|
|
77
|
+
The returned Webpage object contains:
|
|
78
|
+
- key: Webpage identifier
|
|
79
|
+
- url: Full URL of the page
|
|
80
|
+
- created: Creation timestamp
|
|
81
|
+
- updated: Last update timestamp
|
|
82
|
+
"""
|
|
83
|
+
query = Query(node=Node(labels=[Node.Label.WEBPAGE], filters=[Filter(field=Filter.Field.KEY, operator=Filter.Operator.EQUAL, value=key)]))
|
|
84
|
+
return self.api.search.by_query(query)[0][0]
|
|
85
|
+
|
|
86
|
+
def list(self, parent_key=None, filter=None, offset=0, pages=100000) -> tuple:
|
|
87
|
+
"""
|
|
88
|
+
List Webpages, optionally filtered by parent WebApplication.
|
|
89
|
+
|
|
90
|
+
Retrieve Webpage entities with optional filtering capabilities. Can filter by
|
|
91
|
+
parent WebApplication.
|
|
92
|
+
|
|
93
|
+
:param parent_key: Filter pages by specific WebApplication (optional)
|
|
94
|
+
:type parent_key: str or None
|
|
95
|
+
:param filter: Filter pages by specific URL (optional)
|
|
96
|
+
:type filter: str or None
|
|
97
|
+
:param offset: The offset for pagination to retrieve a specific page of results
|
|
98
|
+
:type offset: str or None
|
|
99
|
+
:param pages: Maximum number of pages to retrieve (default: 100000 for all results)
|
|
100
|
+
:type pages: int
|
|
101
|
+
:return: A tuple containing (list of matching Webpages, next page offset)
|
|
102
|
+
:rtype: tuple
|
|
103
|
+
|
|
104
|
+
**Example Usage:**
|
|
105
|
+
>>> # List all WebPages
|
|
106
|
+
>>> pages, offset = sdk.webpage.list()
|
|
107
|
+
|
|
108
|
+
>>> # List pages for specific WebApplication
|
|
109
|
+
>>> pages, offset = sdk.webpage.list(
|
|
110
|
+
... webapp_key="#asset#webapp#https://app.example.com#https://app.example.com")
|
|
111
|
+
|
|
112
|
+
**WebPage Filtering:**
|
|
113
|
+
- parent_key: Filters by parent WebApplication
|
|
114
|
+
- filter: Filters by specific URL
|
|
115
|
+
"""
|
|
116
|
+
if parent_key and not parent_key.startswith('#webapplication#'):
|
|
117
|
+
raise Exception("Invalid WebApplication key format")
|
|
118
|
+
|
|
119
|
+
relationships = []
|
|
120
|
+
filters = []
|
|
121
|
+
if parent_key:
|
|
122
|
+
parentFilter = Filter(field=Filter.Field.KEY, operator=Filter.Operator.EQUAL, value=parent_key)
|
|
123
|
+
relationship = Relationship(label=Relationship.Label.HAS_WEBPAGE, target=Node(labels=[Node.Label.WEBAPPLICATION], filters=[parentFilter]))
|
|
124
|
+
relationships.append(relationship)
|
|
125
|
+
if filter:
|
|
126
|
+
urlFilter = Filter(field=Filter.Field.KEY, operator=Filter.Operator.CONTAINS, value=filter)
|
|
127
|
+
filters.append(urlFilter)
|
|
128
|
+
node = Node(labels=[Node.Label.WEBPAGE], filters=filters, relationships=relationships)
|
|
129
|
+
query = Query(node=node, page=offset, limit=pages)
|
|
130
|
+
return self.api.search.by_query(query, pages)
|
|
131
|
+
|
|
132
|
+
def delete(self, key):
|
|
133
|
+
"""
|
|
134
|
+
Delete a webpage by its key.
|
|
135
|
+
|
|
136
|
+
:param key: The WebPage key identifier
|
|
137
|
+
:type key: str
|
|
138
|
+
"""
|
|
139
|
+
body = {
|
|
140
|
+
'webpage': {
|
|
141
|
+
'key': key
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
self.api.delete('webpage', params={}, body=body)
|
|
145
|
+
|
|
146
|
+
def link_source(self, webpage_key, entity_key):
|
|
147
|
+
"""
|
|
148
|
+
Link a file or repository to a webpage as source code.
|
|
149
|
+
|
|
150
|
+
:param webpage_key: The webpage key in format #webpage#{url}
|
|
151
|
+
:type webpage_key: str
|
|
152
|
+
:param entity_key: The entity key (file or repository) to link. Format: #file#{path} or #repository#{url}#{name}
|
|
153
|
+
:type entity_key: str
|
|
154
|
+
:return: The updated webpage with linked artifacts
|
|
155
|
+
:rtype: dict
|
|
156
|
+
"""
|
|
157
|
+
data = {
|
|
158
|
+
'webpageKey': webpage_key,
|
|
159
|
+
'entityKey': entity_key
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return self.api.put('webpage/link', data, {})
|
|
163
|
+
|
|
164
|
+
def unlink_source(self, webpage_key, entity_key):
|
|
165
|
+
"""
|
|
166
|
+
Unlink a file or repository from a webpage's source code.
|
|
167
|
+
|
|
168
|
+
:param webpage_key: The webpage key in format #webpage#{url}
|
|
169
|
+
:type webpage_key: str
|
|
170
|
+
:param entity_key: The entity key (file or repository) to unlink. Format: #file#{path} or #repository#{url}#{name}
|
|
171
|
+
:type entity_key: str
|
|
172
|
+
:return: The updated webpage with artifacts removed
|
|
173
|
+
:rtype: dict
|
|
174
|
+
"""
|
|
175
|
+
data = {
|
|
176
|
+
'webpageKey': webpage_key,
|
|
177
|
+
'entityKey': entity_key
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return self.api.delete('webpage/link', data, {})
|
|
@@ -108,7 +108,10 @@ class Kind(Enum):
|
|
|
108
108
|
SEED = 'seed'
|
|
109
109
|
PRESEED = 'preseed'
|
|
110
110
|
OTHERS = 'others'
|
|
111
|
+
WEBAPPLICATION = 'webapplication'
|
|
112
|
+
WEBPAGE = 'webpage'
|
|
111
113
|
|
|
112
114
|
EXACT_FLAG = {'exact': 'true'}
|
|
113
115
|
DESCENDING_FLAG = {'desc': 'true'}
|
|
114
116
|
GLOBAL_FLAG = {'global': 'true'}
|
|
117
|
+
USER_FLAG = {'user': 'true'}
|
|
@@ -49,6 +49,8 @@ class Filter:
|
|
|
49
49
|
KEV = 'kev'
|
|
50
50
|
EXPLOIT = 'exploit'
|
|
51
51
|
PRIVATE = 'private'
|
|
52
|
+
PRIMARY_URL = 'primary_url'
|
|
53
|
+
URL = 'url'
|
|
52
54
|
|
|
53
55
|
def __init__(self, field: Field, operator: Operator, value: str, not_: bool = False):
|
|
54
56
|
self.field = field
|
|
@@ -65,6 +67,7 @@ class Relationship:
|
|
|
65
67
|
HAS_VULNERABILITY = 'HAS_VULNERABILITY'
|
|
66
68
|
DISCOVERED = 'DISCOVERED'
|
|
67
69
|
HAS_ATTRIBUTE = 'HAS_ATTRIBUTE'
|
|
70
|
+
HAS_WEBPAGE = 'HAS_WEBPAGE'
|
|
68
71
|
|
|
69
72
|
def __init__(self, label: Label, source: 'Node' = None, target: 'Node' = None, optional: bool = False, length: int = 0):
|
|
70
73
|
self.label = label
|
|
@@ -97,6 +100,8 @@ class Node:
|
|
|
97
100
|
PRESEED = 'Preseed'
|
|
98
101
|
SEED = 'Seed'
|
|
99
102
|
TTL = 'TTL'
|
|
103
|
+
WEBAPPLICATION = 'WebApplication'
|
|
104
|
+
WEBPAGE = 'Webpage'
|
|
100
105
|
|
|
101
106
|
def __init__(self, labels: list[Label] = None, filters: list[Filter] = None,
|
|
102
107
|
relationships: list[Relationship] = None):
|
|
@@ -157,6 +162,8 @@ KIND_TO_LABEL = {
|
|
|
157
162
|
Kind.REPOSITORY.value: Node.Label.REPOSITORY,
|
|
158
163
|
Kind.INTEGRATION.value: Node.Label.INTEGRATION,
|
|
159
164
|
Kind.ADDOMAIN.value: Node.Label.ADDOMAIN,
|
|
165
|
+
Kind.WEBAPPLICATION.value: Node.Label.WEBAPPLICATION,
|
|
166
|
+
Kind.WEBPAGE.value: Node.Label.WEBPAGE,
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
|
|
@@ -71,11 +71,47 @@ class TestAsset:
|
|
|
71
71
|
deleted_assets, _ = self.sdk.search.by_status(Asset.DELETED.value, Kind.ADDOMAIN.value)
|
|
72
72
|
assert any([a['key'] == self.ad_domain_key for a in deleted_assets])
|
|
73
73
|
|
|
74
|
+
def test_add_webapplication(self):
|
|
75
|
+
asset = self.sdk.assets.add(self.webapp_name, self.webapp_url, status=Asset.ACTIVE.value, surface='test-surface', type=Kind.WEBAPPLICATION.value)
|
|
76
|
+
assert asset['key'] == self.webapp_key
|
|
77
|
+
assert len(asset['attackSurface']) == 1
|
|
78
|
+
assert 'test-surface' in asset['attackSurface']
|
|
79
|
+
assert asset['status'] == Asset.ACTIVE.value
|
|
80
|
+
|
|
81
|
+
def test_get_webapplication(self):
|
|
82
|
+
asset = self.sdk.assets.get(self.webapp_key)
|
|
83
|
+
assert asset['key'] == self.webapp_key
|
|
84
|
+
assert asset['group'] == self.webapp_name
|
|
85
|
+
assert asset['identifier'] == self.webapp_url
|
|
86
|
+
assert asset['status'] == Asset.ACTIVE.value
|
|
87
|
+
|
|
88
|
+
def test_list_webapplication(self):
|
|
89
|
+
results, _ = self.sdk.assets.list(asset_type=Kind.WEBAPPLICATION.value)
|
|
90
|
+
assert len(results) > 0
|
|
91
|
+
assert any([a['key'] == self.webapp_key for a in results])
|
|
92
|
+
assert any([a['group'] == self.webapp_name for a in results])
|
|
93
|
+
assert any([a['identifier'] == self.webapp_url for a in results])
|
|
94
|
+
|
|
95
|
+
def test_update_webapplication(self):
|
|
96
|
+
self.sdk.assets.update(self.webapp_key, status=Asset.FROZEN.value, surface='abc')
|
|
97
|
+
asset = self.get_webapplication()
|
|
98
|
+
assert asset['status'] == Asset.FROZEN.value
|
|
99
|
+
assert 'abc' in asset['attackSurface']
|
|
100
|
+
|
|
101
|
+
def test_delete_webapplication(self):
|
|
102
|
+
self.sdk.assets.delete(self.webapp_key)
|
|
103
|
+
assert self.get_webapplication()['status'] == Asset.DELETED.value
|
|
104
|
+
deleted_assets, _ = self.sdk.search.by_status(Asset.DELETED.value, Kind.WEBAPPLICATION.value)
|
|
105
|
+
assert any([a['key'] == self.webapp_key for a in deleted_assets])
|
|
106
|
+
|
|
74
107
|
def get_asset(self):
|
|
75
108
|
return self.sdk.assets.get(self.asset_key)
|
|
76
109
|
|
|
77
110
|
def get_ad_domain(self):
|
|
78
111
|
return self.sdk.assets.get(self.ad_domain_key)
|
|
79
112
|
|
|
113
|
+
def get_webapplication(self):
|
|
114
|
+
return self.sdk.assets.get(self.webapp_key)
|
|
115
|
+
|
|
80
116
|
def teardown_class(self):
|
|
81
117
|
clean_test_entities(self.sdk, self)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from praetorian_cli.sdk.test.utils import make_test_values, clean_test_entities, setup_chariot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.coherence
|
|
9
|
+
class TestConversation:
|
|
10
|
+
"""Test conversation functionality with the Chariot agent"""
|
|
11
|
+
|
|
12
|
+
def setup_class(self):
|
|
13
|
+
self.sdk = setup_chariot()
|
|
14
|
+
make_test_values(self)
|
|
15
|
+
self.conversation_id = None
|
|
16
|
+
self.message_keys = []
|
|
17
|
+
|
|
18
|
+
def test_start_conversation(self):
|
|
19
|
+
"""Test starting a new conversation with the agent"""
|
|
20
|
+
url = self.sdk.url("/planner")
|
|
21
|
+
payload = {
|
|
22
|
+
"message": f"Test conversation started at {int(time.time())}",
|
|
23
|
+
"mode": "query"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
response = self.sdk._make_request("POST", url, json=payload)
|
|
27
|
+
|
|
28
|
+
# Should get successful response
|
|
29
|
+
assert response.status_code == 200
|
|
30
|
+
|
|
31
|
+
result = response.json()
|
|
32
|
+
assert "conversation" in result
|
|
33
|
+
assert "uuid" in result["conversation"]
|
|
34
|
+
|
|
35
|
+
self.conversation_id = result["conversation"]["uuid"]
|
|
36
|
+
assert self.conversation_id is not None
|
|
37
|
+
assert len(self.conversation_id) > 0
|
|
38
|
+
|
|
39
|
+
def test_send_message_with_existing_conversation(self):
|
|
40
|
+
"""Test sending a message to an existing conversation"""
|
|
41
|
+
# First, start a new conversation
|
|
42
|
+
url = self.sdk.url("/planner")
|
|
43
|
+
initial_payload = {
|
|
44
|
+
"message": f"Initial message for follow-up test at {int(time.time())}",
|
|
45
|
+
"mode": "query"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
response = self.sdk._make_request("POST", url, json=initial_payload)
|
|
49
|
+
assert response.status_code == 200
|
|
50
|
+
|
|
51
|
+
result = response.json()
|
|
52
|
+
conversation_id = result["conversation"]["uuid"]
|
|
53
|
+
|
|
54
|
+
# Now send a follow-up message to the existing conversation
|
|
55
|
+
follow_up_payload = {
|
|
56
|
+
"message": f"Follow-up message in existing conversation at {int(time.time())}",
|
|
57
|
+
"mode": "query",
|
|
58
|
+
"conversationId": conversation_id
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
response = self.sdk._make_request("POST", url, json=follow_up_payload)
|
|
62
|
+
|
|
63
|
+
# Should get successful response
|
|
64
|
+
assert response.status_code == 200
|
|
65
|
+
|
|
66
|
+
def test_read_conversation_messages(self):
|
|
67
|
+
"""Test reading messages from a conversation"""
|
|
68
|
+
# First, create a conversation and send a message
|
|
69
|
+
url = self.sdk.url("/planner")
|
|
70
|
+
payload = {
|
|
71
|
+
"message": f"Test message for reading at {int(time.time())}",
|
|
72
|
+
"mode": "query"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
response = self.sdk._make_request("POST", url, json=payload)
|
|
76
|
+
assert response.status_code == 200
|
|
77
|
+
|
|
78
|
+
result = response.json()
|
|
79
|
+
conversation_id = result["conversation"]["uuid"]
|
|
80
|
+
|
|
81
|
+
# Wait a moment for message to be stored
|
|
82
|
+
time.sleep(2)
|
|
83
|
+
|
|
84
|
+
# Now try to read messages from this conversation
|
|
85
|
+
messages, offset = self.sdk.search.by_key_prefix(
|
|
86
|
+
f"#message#{conversation_id}#", user=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Should have at least the user message
|
|
90
|
+
assert isinstance(messages, list)
|
|
91
|
+
# Note: messages might be empty immediately after creation due to async processing
|
|
92
|
+
# This is still a valid test as it verifies the search functionality works
|
|
93
|
+
|
|
94
|
+
def test_read_conversations_list(self):
|
|
95
|
+
"""Test reading list of conversations"""
|
|
96
|
+
# First create a test conversation
|
|
97
|
+
url = self.sdk.url("/planner")
|
|
98
|
+
payload = {
|
|
99
|
+
"message": f"Test conversation for listing at {int(time.time())}",
|
|
100
|
+
"mode": "query"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
response = self.sdk._make_request("POST", url, json=payload)
|
|
104
|
+
assert response.status_code == 200
|
|
105
|
+
|
|
106
|
+
# Wait a moment for conversation to be stored
|
|
107
|
+
time.sleep(1)
|
|
108
|
+
|
|
109
|
+
# Now search for conversations
|
|
110
|
+
conversations, offset = self.sdk.search.by_key_prefix(
|
|
111
|
+
"#conversation#", user=True
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Should be able to search for conversations (list might be empty or contain conversations)
|
|
115
|
+
assert isinstance(conversations, list)
|
|
116
|
+
|
|
117
|
+
def test_conversation_api_error_handling(self):
|
|
118
|
+
"""Test handling of malformed requests"""
|
|
119
|
+
url = self.sdk.url("/planner")
|
|
120
|
+
# Send malformed payload (missing required fields)
|
|
121
|
+
malformed_payload = {
|
|
122
|
+
"invalid_field": "should cause error"
|
|
123
|
+
# Missing required "message" field
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
response = self.sdk._make_request("POST", url, json=malformed_payload)
|
|
127
|
+
|
|
128
|
+
# Should get a 4xx error for bad request
|
|
129
|
+
assert response.status_code >= 400
|
|
130
|
+
|
|
131
|
+
def test_conversation_modes(self):
|
|
132
|
+
"""Test different conversation modes (query vs agent)"""
|
|
133
|
+
url = self.sdk.url("/planner")
|
|
134
|
+
|
|
135
|
+
# Test query mode
|
|
136
|
+
query_payload = {
|
|
137
|
+
"message": f"Test query mode at {int(time.time())}",
|
|
138
|
+
"mode": "query"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
response = self.sdk._make_request("POST", url, json=query_payload)
|
|
142
|
+
assert response.status_code == 200
|
|
143
|
+
|
|
144
|
+
result = response.json()
|
|
145
|
+
assert "conversation" in result
|
|
146
|
+
|
|
147
|
+
# Test agent mode
|
|
148
|
+
agent_payload = {
|
|
149
|
+
"message": f"Test agent mode at {int(time.time())}",
|
|
150
|
+
"mode": "agent"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
response = self.sdk._make_request("POST", url, json=agent_payload)
|
|
154
|
+
assert response.status_code == 200
|
|
155
|
+
|
|
156
|
+
result = response.json()
|
|
157
|
+
assert "conversation" in result
|
|
158
|
+
|
|
159
|
+
def test_message_polling_integration(self):
|
|
160
|
+
"""Test integration of message polling to check for new messages"""
|
|
161
|
+
# Create a conversation first
|
|
162
|
+
url = self.sdk.url("/planner")
|
|
163
|
+
payload = {
|
|
164
|
+
"message": f"Test message for polling at {int(time.time())}",
|
|
165
|
+
"mode": "query"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
response = self.sdk._make_request("POST", url, json=payload)
|
|
169
|
+
assert response.status_code == 200
|
|
170
|
+
|
|
171
|
+
result = response.json()
|
|
172
|
+
conversation_id = result["conversation"]["uuid"]
|
|
173
|
+
|
|
174
|
+
# Test polling for messages (first call)
|
|
175
|
+
messages, offset = self.sdk.search.by_key_prefix(
|
|
176
|
+
f"#message#{conversation_id}#", user=True
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
initial_count = len(messages) if messages else 0
|
|
180
|
+
|
|
181
|
+
# Wait a moment and check again (simulating polling)
|
|
182
|
+
time.sleep(1)
|
|
183
|
+
|
|
184
|
+
messages, offset = self.sdk.search.by_key_prefix(
|
|
185
|
+
f"#message#{conversation_id}#", user=True
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Should still return valid result (messages may or may not have changed)
|
|
189
|
+
assert isinstance(messages, list)
|
|
190
|
+
|
|
191
|
+
def teardown_class(self):
|
|
192
|
+
"""Clean up test data"""
|
|
193
|
+
# Note: Conversations and messages are typically read-only in tests
|
|
194
|
+
# Real cleanup would depend on having delete methods for conversations
|
|
195
|
+
pass
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from praetorian_cli.sdk.test.utils import make_test_values, setup_chariot
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.coherence
|
|
7
|
+
class TestWebpage:
|
|
8
|
+
"""Test suite for the Webpage entity class."""
|
|
9
|
+
|
|
10
|
+
def setup_class(self):
|
|
11
|
+
self.sdk = setup_chariot()
|
|
12
|
+
make_test_values(self)
|
|
13
|
+
|
|
14
|
+
def test_add_webpage(self):
|
|
15
|
+
"""Test adding a Webpage with URL provided."""
|
|
16
|
+
result = self.sdk.webpage.add(self.webpage_url)
|
|
17
|
+
|
|
18
|
+
assert result is not None
|
|
19
|
+
webpage = result.get('webpages')[0]
|
|
20
|
+
assert webpage.get('key') == self.webpage_key
|
|
21
|
+
assert webpage.get('url') == self.webpage_url
|
|
22
|
+
|
|
23
|
+
def test_get_webpage(self):
|
|
24
|
+
"""Test retrieving a Webpage by key."""
|
|
25
|
+
result = self.sdk.webpage.get(self.webpage_key)
|
|
26
|
+
assert result is not None
|
|
27
|
+
assert result.get('key') == self.webpage_key
|
|
28
|
+
assert result.get('url') == self.webpage_url
|
|
29
|
+
|
|
30
|
+
def test_list_webpages(self):
|
|
31
|
+
"""Test listing Webpages."""
|
|
32
|
+
results, offset = self.sdk.webpage.list(filter=self.webpage_url[:len(self.webpage_url)//2])
|
|
33
|
+
assert isinstance(results, list)
|
|
34
|
+
assert len(results) > 0
|
|
35
|
+
assert any(r.get('key') == self.webpage_key for r in results)
|
|
36
|
+
assert any(r.get('url') == self.webpage_url for r in results)
|
|
37
|
+
|
|
38
|
+
def test_add_webpage_empty_url_raises_exception(self):
|
|
39
|
+
"""Test that adding a Webpage with empty URL raises an exception."""
|
|
40
|
+
with pytest.raises(Exception, match="URL is required for Webpage"):
|
|
41
|
+
self.sdk.webpage.add("")
|
|
42
|
+
|
|
43
|
+
def test_add_webpage_none_url_raises_exception(self):
|
|
44
|
+
"""Test that adding a Webpage with None URL raises an exception."""
|
|
45
|
+
with pytest.raises(Exception, match="URL is required for Webpage"):
|
|
46
|
+
self.sdk.webpage.add(None)
|
|
@@ -5,6 +5,7 @@ from subprocess import run
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from praetorian_cli.sdk.model.globals import AddRisk, Asset, Risk, Seed, Preseed
|
|
8
|
+
from praetorian_cli.sdk.model.utils import configuration_key
|
|
8
9
|
from praetorian_cli.sdk.test.utils import epoch_micro, random_ip, make_test_values, clean_test_entities, setup_chariot
|
|
9
10
|
|
|
10
11
|
|
|
@@ -210,6 +211,21 @@ class TestZCli:
|
|
|
210
211
|
self.verify(f'list accounts -f {o.email}', [o.email])
|
|
211
212
|
self.verify(f'unlink account {o.email}')
|
|
212
213
|
self.verify(f'list accounts -f {o.email}')
|
|
214
|
+
|
|
215
|
+
def test_webpage_source_cli(self):
|
|
216
|
+
o = make_test_values(lambda: None)
|
|
217
|
+
|
|
218
|
+
self.verify(f'add webpage --url "{o.webpage_url}"')
|
|
219
|
+
|
|
220
|
+
file_key = f'"#file#test-nonexistent-{epoch_micro()}-2.txt"'
|
|
221
|
+
|
|
222
|
+
self.verify(f'link webpage-source "{o.webpage_key}" {file_key}',
|
|
223
|
+
expected_stderr=['not found'])
|
|
224
|
+
self.verify(f'unlink webpage-source "{o.webpage_key}" {file_key}',
|
|
225
|
+
expected_stdout=[o.webpage_key])
|
|
226
|
+
|
|
227
|
+
# Clean up
|
|
228
|
+
self.verify(f'delete webpage "{o.webpage_key}"', ignore_stdout=True)
|
|
213
229
|
|
|
214
230
|
def test_integration_cli(self):
|
|
215
231
|
self.verify('list integrations', ignore_stdout=True)
|
|
@@ -258,6 +274,38 @@ class TestZCli:
|
|
|
258
274
|
|
|
259
275
|
self.verify(f'delete configuration "{o.configuration_key}"', ignore_stdout=True)
|
|
260
276
|
|
|
277
|
+
string_name = f'{o.configuration_name}-string'
|
|
278
|
+
string_key = configuration_key(string_name)
|
|
279
|
+
self.verify(f'add configuration --name "{string_name}" --string PAID_MS')
|
|
280
|
+
self.verify(f'get configuration "{string_key}"', expected_stdout=[string_key, string_name, '"value": "PAID_MS"'])
|
|
281
|
+
self.verify(f'delete configuration "{string_key}"', ignore_stdout=True)
|
|
282
|
+
|
|
283
|
+
integer_name = f'{o.configuration_name}-integer'
|
|
284
|
+
integer_key = configuration_key(integer_name)
|
|
285
|
+
self.verify(f'add configuration --name "{integer_name}" --integer 123')
|
|
286
|
+
self.verify(f'get configuration "{integer_key}"', expected_stdout=[integer_key, integer_name, '"value": 123'])
|
|
287
|
+
self.verify(f'delete configuration "{integer_key}"', ignore_stdout=True)
|
|
288
|
+
|
|
289
|
+
float_name = f'{o.configuration_name}-float'
|
|
290
|
+
float_key = configuration_key(float_name)
|
|
291
|
+
self.verify(f'add configuration --name "{float_name}" --float 1.5')
|
|
292
|
+
self.verify(f'get configuration "{float_key}"', expected_stdout=[float_key, float_name, '"value": 1.5'])
|
|
293
|
+
self.verify(f'delete configuration "{float_key}"', ignore_stdout=True)
|
|
294
|
+
|
|
295
|
+
def test_webapplication_cli(self):
|
|
296
|
+
o = make_test_values(lambda: None)
|
|
297
|
+
self.verify(f'add asset --dns "{o.webapp_name}" --name "{o.webapp_url}" --type webapplication')
|
|
298
|
+
self.verify(f'get asset "{o.webapp_key}"', expected_stdout=[o.webapp_key, o.webapp_url, o.webapp_name, '"status"', '"A"'])
|
|
299
|
+
self.verify(f'list assets -f "{o.webapp_name}"', expected_stdout=[o.webapp_key])
|
|
300
|
+
self.verify(f'delete asset "{o.webapp_key}"', ignore_stdout=True)
|
|
301
|
+
|
|
302
|
+
def test_webpage_cli(self):
|
|
303
|
+
o = make_test_values(lambda: None)
|
|
304
|
+
self.verify(f'add webpage --url "{o.webpage_url}"')
|
|
305
|
+
self.verify(f'get webpage "{o.webpage_key}"', expected_stdout=[o.webpage_key, o.webpage_url, '"status"', '"A"'])
|
|
306
|
+
self.verify(f'list webpages -p all -f "{o.webpage_url[:len(o.webpage_url)//2]}"', expected_stdout=[o.webpage_key])
|
|
307
|
+
self.verify(f'delete webpage "{o.webpage_key}"', ignore_stdout=True)
|
|
308
|
+
|
|
261
309
|
def test_help_cli(self):
|
|
262
310
|
self.verify('--help', ignore_stdout=True)
|
|
263
311
|
self.verify('list --help', ignore_stdout=True)
|
|
@@ -310,9 +358,16 @@ class TestZCli:
|
|
|
310
358
|
|
|
311
359
|
self.verify('link --help', ignore_stdout=True)
|
|
312
360
|
self.verify('link account --help', ignore_stdout=True)
|
|
361
|
+
|
|
362
|
+
# Agent commands
|
|
363
|
+
self.verify('agent --help', ignore_stdout=True)
|
|
364
|
+
self.verify('agent conversation --help', ignore_stdout=True)
|
|
365
|
+
self.verify('agent mcp --help', ignore_stdout=True)
|
|
366
|
+
self.verify('link webpage-source --help', ignore_stdout=True)
|
|
313
367
|
|
|
314
368
|
self.verify('unlink --help', ignore_stdout=True)
|
|
315
369
|
self.verify('unlink account --help', ignore_stdout=True)
|
|
370
|
+
self.verify('unlink webpage-source --help', ignore_stdout=True)
|
|
316
371
|
|
|
317
372
|
self.verify('delete --help', ignore_stdout=True)
|
|
318
373
|
self.verify('delete asset --help', ignore_stdout=True)
|
praetorian_cli/sdk/test/utils.py
CHANGED
|
@@ -66,6 +66,11 @@ def make_test_values(o):
|
|
|
66
66
|
o.configuration_value = {o.configuration_name: o.configuration_name}
|
|
67
67
|
o.configuration_key = configuration_key(o.configuration_name)
|
|
68
68
|
o.key_name = f'test-key-name-{epoch_micro()}'
|
|
69
|
+
o.webapp_name = f'test-webapp-name-{epoch_micro()}'
|
|
70
|
+
o.webapp_url = f'https://test-webapp-{epoch_micro()}.com/'
|
|
71
|
+
o.webapp_key = f'#webapplication#{o.webapp_url}'
|
|
72
|
+
o.webpage_url = f'https://test-webpage-{epoch_micro()}.com/index.html'
|
|
73
|
+
o.webpage_key = f'#webpage#{o.webpage_url}'
|
|
69
74
|
return o
|
|
70
75
|
|
|
71
76
|
|