Skip to content

Commit 733d209

Browse files
authored
Migrate collection operations from APIHarnessV2 to CustomStorage (#118)
1 parent 8e88e60 commit 733d209

4 files changed

Lines changed: 149 additions & 69 deletions

File tree

e2e/src/pages/WorkflowsPage.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,4 +246,81 @@ export class WorkflowsPage extends BasePage {
246246
`Execute and verify workflow: ${workflowName}`
247247
);
248248
}
249+
250+
/**
251+
* Check the actual execution status by viewing the execution details.
252+
* Navigates to the execution log, waits for the execution to complete,
253+
* expands the execution row, and checks the status.
254+
*/
255+
async verifyWorkflowExecutionCompleted(timeoutMs = 120000): Promise<void> {
256+
return this.withTiming(
257+
async () => {
258+
this.logger.info('Checking workflow execution status in detail view');
259+
260+
// The "View" link opens in a new tab - capture it
261+
const viewLink = this.page.getByRole('link', { name: /^view$/i });
262+
await viewLink.waitFor({ state: 'visible', timeout: 10000 });
263+
264+
const [executionPage] = await Promise.all([
265+
this.page.context().waitForEvent('page'),
266+
viewLink.click(),
267+
]);
268+
269+
// Wait for the new tab to load (execution pages can be slow to render)
270+
await executionPage.waitForLoadState('networkidle');
271+
await executionPage.waitForLoadState('domcontentloaded');
272+
this.logger.info('Execution page opened in new tab');
273+
274+
// Wait for "Execution status" to appear (proves execution details loaded)
275+
const statusLabel = executionPage.getByText('Execution status');
276+
await statusLabel.waitFor({ state: 'visible', timeout: 60000 });
277+
this.logger.info('Execution details visible');
278+
279+
// Poll until execution reaches a terminal state
280+
this.logger.info(`Waiting up to ${timeoutMs / 1000}s for execution to complete...`);
281+
282+
const startTime = Date.now();
283+
while (Date.now() - startTime < timeoutMs) {
284+
// Re-find status label each iteration (DOM recreated on reload)
285+
const currentStatusLabel = executionPage.getByText('Execution status');
286+
await currentStatusLabel.waitFor({ state: 'visible', timeout: 15000 });
287+
const statusContainer = currentStatusLabel.locator('..');
288+
const statusText = await statusContainer.textContent() || '';
289+
const currentStatus = statusText.replace('Execution status', '').trim();
290+
this.logger.info(`Current status: ${currentStatus}`);
291+
292+
if (currentStatus.toLowerCase().includes('failed')) {
293+
// Capture error details
294+
const pageContent = await executionPage.textContent('body') || '';
295+
const messageMatch = pageContent.match(/"message":\s*"([^"]+)"/);
296+
297+
let errorMessage = 'Workflow action failed';
298+
if (messageMatch) {
299+
errorMessage = messageMatch[1];
300+
}
301+
302+
await executionPage.close();
303+
this.logger.error(`Workflow execution failed: ${errorMessage}`);
304+
throw new Error(`Workflow execution failed: ${errorMessage}`);
305+
}
306+
307+
if (!currentStatus.toLowerCase().includes('in progress')) {
308+
// Terminal state that isn't "Failed"
309+
await executionPage.close();
310+
this.logger.success(`Workflow execution completed with status: ${currentStatus}`);
311+
return;
312+
}
313+
314+
await executionPage.waitForTimeout(5000);
315+
316+
// Reload to get updated status - the page doesn't auto-refresh
317+
await executionPage.reload({ waitUntil: 'networkidle' });
318+
}
319+
320+
await executionPage.close();
321+
throw new Error('Workflow execution timed out - still in progress');
322+
},
323+
'Verify workflow execution completed'
324+
);
325+
}
249326
}

e2e/tests/foundry.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from '../src/fixtures';
1+
import { test } from '../src/fixtures';
22

33
test.describe.configure({ mode: 'serial' });
44

@@ -9,16 +9,21 @@ test.describe('Functions with Python - E2E Tests', () => {
99
});
1010

1111
test('should execute Test hello function workflow', async ({ workflowsPage }) => {
12+
test.setTimeout(180000);
1213
await workflowsPage.navigateToWorkflows();
1314
await workflowsPage.executeAndVerifyWorkflow('Test hello function');
15+
await workflowsPage.verifyWorkflowExecutionCompleted();
1416
});
1517

1618
test('should execute Test log-event function workflow', async ({ workflowsPage }) => {
19+
test.setTimeout(180000);
1720
await workflowsPage.navigateToWorkflows();
1821
await workflowsPage.executeAndVerifyWorkflow('Test log-event function');
22+
await workflowsPage.verifyWorkflowExecutionCompleted();
1923
});
2024

2125
test('should execute Test host-details function workflow', async ({ workflowsPage, hostManagementPage }) => {
26+
test.setTimeout(180000);
2227
// Get first available host ID
2328
const hostId = await hostManagementPage.getFirstHostId();
2429

@@ -32,6 +37,7 @@ test.describe('Functions with Python - E2E Tests', () => {
3237
await workflowsPage.executeAndVerifyWorkflow('Test host-details function', {
3338
'Host ID': hostId
3439
});
40+
await workflowsPage.verifyWorkflowExecutionCompleted();
3541
});
3642

3743
test('should render Test servicenow function workflow (without execution)', async ({ workflowsPage }) => {

functions/log-event/main.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@
55
import uuid
66

77
from crowdstrike.foundry.function import Function, Request, Response, APIError
8-
from falconpy import APIHarnessV2
8+
from falconpy import CustomStorage
99

1010
FUNC = Function.instance()
1111

1212

13+
def _app_headers() -> dict:
14+
"""Build app headers for CustomStorage construction."""
15+
app_id = os.environ.get("APP_ID")
16+
if app_id:
17+
return {"X-CS-APP-ID": app_id}
18+
return {}
19+
20+
1321
@FUNC.handler(method="POST", path="/log-event")
1422
def on_post(request: Request) -> Response:
1523
"""
@@ -40,22 +48,12 @@ def on_post(request: Request) -> Response:
4048
"timestamp": int(time.time())
4149
}
4250

43-
# Allow setting APP_ID as an env variable for local testing
44-
headers = {}
45-
if os.environ.get("APP_ID"):
46-
headers = {
47-
"X-CS-APP-ID": os.environ.get("APP_ID")
48-
}
49-
50-
api_client = APIHarnessV2()
51+
custom_storage = CustomStorage(ext_headers=_app_headers())
5152
collection_name = "event_logs"
5253

53-
response = api_client.command("PutObject",
54-
body=json_data,
55-
collection_name=collection_name,
56-
object_key=event_id,
57-
headers=headers
58-
)
54+
response = custom_storage.PutObject(body=json_data,
55+
collection_name=collection_name,
56+
object_key=event_id)
5957

6058
if response["status_code"] != 200:
6159
error_message = response.get("error", {}).get("message", "Unknown error")
@@ -68,17 +66,14 @@ def on_post(request: Request) -> Response:
6866
)
6967

7068
# Query the collection to retrieve the event by id
71-
query_response = api_client.command("SearchObjects",
72-
filter=f"event_id:'{event_id}'",
73-
collection_name=collection_name,
74-
limit=5,
75-
headers=headers
76-
)
69+
query_response = custom_storage.SearchObjects(filter=f"event_id:'{event_id}'",
70+
collection_name=collection_name,
71+
limit=5)
7772

7873
return Response(
7974
body={
8075
"stored": True,
81-
"metadata": query_response.get("body").get("resources", [])
76+
"metadata": query_response.get("body", {}).get("resources", [])
8277
},
8378
code=200
8479
)

functions/log-event/test_main.py

Lines changed: 48 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -29,37 +29,37 @@ def setUp(self):
2929

3030
importlib.reload(main)
3131

32-
@patch('main.APIHarnessV2')
32+
@patch('main.CustomStorage')
3333
@patch('main.uuid.uuid4')
3434
@patch('main.time.time')
35-
def test_on_post_success(self, mock_time, mock_uuid, mock_api_harness_class):
35+
def test_on_post_success(self, mock_time, mock_uuid, mock_custom_storage_class):
3636
"""Test successful POST request with valid event_data in body."""
3737
# Mock dependencies
3838
mock_uuid.return_value = MagicMock()
3939
mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123")
4040
mock_time.return_value = 1690123456
4141

42-
# Mock APIHarnessV2 instance
42+
# Mock CustomStorage instance
4343
mock_api_instance = MagicMock()
44-
mock_api_harness_class.return_value = mock_api_instance
44+
mock_custom_storage_class.return_value = mock_api_instance
4545

4646
# Mock successful PutObject response
47-
mock_api_instance.command.side_effect = [
48-
{ # PutObject response
49-
"status_code": 200,
50-
"body": {"success": True}
51-
},
52-
{ # SearchObjects response
53-
"status_code": 200,
54-
"body": {
55-
"resources": [{
56-
"event_id": "test-event-id-123",
57-
"data": {"test": "data"},
58-
"timestamp": 1690123456
59-
}]
60-
}
47+
mock_api_instance.PutObject.return_value = {
48+
"status_code": 200,
49+
"body": {"success": True}
50+
}
51+
52+
# Mock successful SearchObjects response
53+
mock_api_instance.SearchObjects.return_value = {
54+
"status_code": 200,
55+
"body": {
56+
"resources": [{
57+
"event_id": "test-event-id-123",
58+
"data": {"test": "data"},
59+
"timestamp": 1690123456
60+
}]
6161
}
62-
]
62+
}
6363

6464
request = Request()
6565
request.body = {
@@ -73,21 +73,18 @@ def test_on_post_success(self, mock_time, mock_uuid, mock_api_harness_class):
7373
self.assertIn("metadata", response.body)
7474
self.assertEqual(len(response.body["metadata"]), 1)
7575

76-
# Verify API calls
77-
self.assertEqual(mock_api_instance.command.call_count, 2)
78-
7976
# Verify PutObject call
80-
put_call = mock_api_instance.command.call_args_list[0]
81-
self.assertEqual(put_call[0][0], "PutObject")
77+
mock_api_instance.PutObject.assert_called_once()
78+
put_call = mock_api_instance.PutObject.call_args
8279
self.assertEqual(put_call[1]["collection_name"], "event_logs")
8380
self.assertEqual(put_call[1]["object_key"], "test-event-id-123")
8481
self.assertEqual(put_call[1]["body"]["event_id"], "test-event-id-123")
8582
self.assertEqual(put_call[1]["body"]["data"], {"test": "data", "message": "test event"})
8683
self.assertEqual(put_call[1]["body"]["timestamp"], 1690123456)
8784

8885
# Verify SearchObjects call
89-
search_call = mock_api_instance.command.call_args_list[1]
90-
self.assertEqual(search_call[0][0], "SearchObjects")
86+
mock_api_instance.SearchObjects.assert_called_once()
87+
search_call = mock_api_instance.SearchObjects.call_args
9188
self.assertEqual(search_call[1]["filter"], "event_id:'test-event-id-123'")
9289
self.assertEqual(search_call[1]["collection_name"], "event_logs")
9390

@@ -101,20 +98,20 @@ def test_on_post_missing_event_data(self):
10198
self.assertEqual(len(response.errors), 1)
10299
self.assertEqual(response.errors[0].message, "missing event_data")
103100

104-
@patch('main.APIHarnessV2')
101+
@patch('main.CustomStorage')
105102
@patch('main.uuid.uuid4')
106103
@patch('main.time.time')
107-
def test_on_post_put_object_error(self, mock_time, mock_uuid, mock_api_harness_class):
104+
def test_on_post_put_object_error(self, mock_time, mock_uuid, mock_custom_storage_class):
108105
"""Test POST request when PutObject API returns an error."""
109106
# Mock dependencies
110107
mock_uuid.return_value = MagicMock()
111108
mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123")
112109
mock_time.return_value = 1690123456
113110

114-
# Mock APIHarnessV2 instance with error response
111+
# Mock CustomStorage instance with error response
115112
mock_api_instance = MagicMock()
116-
mock_api_harness_class.return_value = mock_api_instance
117-
mock_api_instance.command.return_value = {
113+
mock_custom_storage_class.return_value = mock_api_instance
114+
mock_api_instance.PutObject.return_value = {
118115
"status_code": 500,
119116
"error": {"message": "Internal server error"}
120117
}
@@ -130,18 +127,18 @@ def test_on_post_put_object_error(self, mock_time, mock_uuid, mock_api_harness_c
130127
self.assertEqual(len(response.errors), 1)
131128
self.assertIn("Failed to store event: Internal server error", response.errors[0].message)
132129

133-
@patch('main.APIHarnessV2')
130+
@patch('main.CustomStorage')
134131
@patch('main.uuid.uuid4')
135132
@patch('main.time.time')
136-
def test_on_post_exception_handling(self, mock_time, mock_uuid, mock_api_harness_class):
133+
def test_on_post_exception_handling(self, mock_time, mock_uuid, mock_custom_storage_class):
137134
"""Test POST request when an exception is raised."""
138135
# Mock dependencies
139136
mock_uuid.return_value = MagicMock()
140137
mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123")
141138
mock_time.return_value = 1690123456
142139

143-
# Mock APIHarnessV2 to raise an exception
144-
mock_api_harness_class.side_effect = ConnectionError("Connection failed")
140+
# Mock CustomStorage to raise an exception
141+
mock_custom_storage_class.side_effect = ConnectionError("Connection failed")
145142

146143
request = Request()
147144
request.body = {
@@ -155,23 +152,27 @@ def test_on_post_exception_handling(self, mock_time, mock_uuid, mock_api_harness
155152
self.assertIn("Error saving collection: Connection failed", response.errors[0].message)
156153

157154
@patch.dict('main.os.environ', {'APP_ID': 'test-app-123'})
158-
@patch('main.APIHarnessV2')
155+
@patch('main.CustomStorage')
159156
@patch('main.uuid.uuid4')
160157
@patch('main.time.time')
161-
def test_on_post_with_app_id_header(self, mock_time, mock_uuid, mock_api_harness_class):
158+
def test_on_post_with_app_id_header(self, mock_time, mock_uuid, mock_custom_storage_class):
162159
"""Test POST request with APP_ID environment variable set."""
163160
# Mock dependencies
164161
mock_uuid.return_value = MagicMock()
165162
mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123")
166163
mock_time.return_value = 1690123456
167164

168-
# Mock APIHarnessV2 instance
165+
# Mock CustomStorage instance
169166
mock_api_instance = MagicMock()
170-
mock_api_harness_class.return_value = mock_api_instance
171-
mock_api_instance.command.side_effect = [
172-
{"status_code": 200, "body": {"success": True}},
173-
{"status_code": 200, "body": {"resources": []}}
174-
]
167+
mock_custom_storage_class.return_value = mock_api_instance
168+
mock_api_instance.PutObject.return_value = {
169+
"status_code": 200,
170+
"body": {"success": True}
171+
}
172+
mock_api_instance.SearchObjects.return_value = {
173+
"status_code": 200,
174+
"body": {"resources": []}
175+
}
175176

176177
request = Request()
177178
request.body = {
@@ -182,9 +183,10 @@ def test_on_post_with_app_id_header(self, mock_time, mock_uuid, mock_api_harness
182183

183184
self.assertEqual(response.code, 200)
184185

185-
# Verify that headers with APP_ID were passed to both API calls
186-
for call in mock_api_instance.command.call_args_list:
187-
self.assertEqual(call[1]["headers"], {"X-CS-APP-ID": "test-app-123"})
186+
# Verify that CustomStorage was constructed with ext_headers containing APP_ID
187+
mock_custom_storage_class.assert_called_once_with(
188+
ext_headers={"X-CS-APP-ID": "test-app-123"}
189+
)
188190

189191

190192
if __name__ == "__main__":

0 commit comments

Comments
 (0)