From 8c25021dfefe49526e905ceb7f1e91db770d6372 Mon Sep 17 00:00:00 2001 From: Scott Horton Date: Tue, 13 Oct 2026 16:50:36 -0500 Subject: [PATCH] Improve action-result reporting and expand regex validation --- CHANGELOG.md | 8 + DEPLOYMENT.md | 26 +++ README.md | 5 +- frontend/src/ActionResults.tsx | 87 ++++++++++ frontend/src/AutomationActivity.tsx | 78 +++------ frontend/src/FieldValidation.tsx | 37 +++-- frontend/src/ReceiptTimeline.tsx | 6 +- frontend/src/RunCounts.tsx | 23 ++- frontend/src/action-results.js | 19 +++ frontend/src/receipt-audit.js | 3 +- frontend/src/run-status.js | 7 + frontend/src/styles.css | 44 +++++- frontend/src/types.ts | 21 ++- package-lock.json | 4 +- package.json | 2 +- scripts/dev_server.py | 2 +- scripts/package_app.py | 2 + .../bin/actionstack/run_activity.py | 6 +- splunk_actionstack/bin/actionstack/service.py | 2 +- splunk_actionstack/bin/actionstack/soar.py | 71 ++++++--- .../bin/actionstack/validation_policies.py | 4 +- splunk_actionstack/default/app.conf | 2 +- tests/action-results.test.mjs | 26 +++ tests/receipt-audit.test.mjs | 20 +++ tests/test_field_catalog.py | 32 +++- tests/test_run_activity.py | 149 +++++++++++++++++- tests/test_unified_validation.py | 2 +- 27 files changed, 568 insertions(+), 120 deletions(-) create mode 100644 frontend/src/ActionResults.tsx create mode 100644 frontend/src/action-results.js create mode 100644 frontend/src/run-status.js create mode 100644 tests/action-results.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 85425fb..9dcf809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.10 + +- Group receipt details by each returned SOAR action result, keeping its status, summary, and result data together without matching submitted input order. Expanded details stay with their result when returned results reorder. +- Show Mixed results and success/failure counts when an action has both outcomes, retaining SOAR's reported run status. Include failures without payloads and count reported results beyond the detail preview limit. +- Read additional action-result pages within a five-request budget. Mark incomplete result counts and show Results incomplete when omitted results could contain failures. List and receipt totals consistently count SOAR run statuses, labeled separately from individual result outcomes. +- Expand regex validation to 2,048 characters with a multiline editor and visible character count. Preserve the evaluation timeout and validate the same limit in the builder and backend. +- Add search head cluster troubleshooting for stale app-list versions, including staged files, deployer-local versions merged into member defaults, member-local overrides, and metadata reloads. Check lockfile versions alongside Splunk package metadata during packaging. + ## 0.5.9 - Select existing workspace categories or add a new category in form settings. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 8e76dc4..e1e68b7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -13,6 +13,32 @@ The REST handler supports Python 3.9 and 3.13. The app uses the signed-in Splunk After installation, open **ActionStack** as a Splunk administrator. Existing forms, workspaces, and submissions are retained when updating the app. Refresh Splunk Web after an update to load the new browser assets. +### Search head cluster upgrades and app-list versions + +Stage the complete updated app at `$SPLUNK_HOME/etc/shcluster/apps/splunk_actionstack` on the deployer, then use your normal SHC bundle deployment procedure. Installing a package into the deployer's own `etc/apps` directory alone does not update the staged bundle. ActionStack packages declare `deployer_push_mode = full`; check for environment overrides. With this mode, updated default files replace member defaults, while member-local settings take precedence during merging. [Splunk deployer documentation](https://help.splunk.com/en/splunk-enterprise/administer/distributed-search/9.3/update-search-head-cluster-members/use-the-deployer-to-distribute-apps-and-configuration-updates). + +The version inside ActionStack comes from the frontend build. Splunk's app list uses the `[launcher] version` setting in `app.conf`. If they disagree, inspect the archive, staged app, and affected member rather than assuming the whole app failed to update. [Splunk app.conf reference](https://help.splunk.com/en/splunk-enterprise/administer/admin-manual/10.0/configuration-file-reference/10.0.3-configuration-file-reference/app.conf). + +For the 0.5.10 package, inspect its shipped configuration: + +```sh +tar -xOf splunk_actionstack-0.5.10.spl splunk_actionstack/default/app.conf +``` + +On each affected search head, run: + +```sh +"$SPLUNK_HOME/bin/splunk" btool app list launcher --app=splunk_actionstack --debug +``` + +The output identifies the winning version and source file on disk. It does not guarantee that the running process has loaded that value. [Splunk btool documentation](https://help.splunk.com/en/splunk-enterprise/administer/troubleshoot/10.4/first-steps/use-btool-to-troubleshoot-configurations). + +- **Old version in the member's `default/app.conf`:** this is not a browser-cache issue. Compare it with the package and the deployer's staged `default/app.conf`. Also inspect `etc/shcluster/apps/splunk_actionstack/local/app.conf` on the **deployer**, even if the member has no local file. In `merge_to_default` mode, a stale deployer-local version overrides the staged default and is written into the member's default file, while updated frontend assets can still deploy. If both staged copies are correct, check deployment status and the generated bundle. [Splunk merge behavior](https://help.splunk.com/en/splunk-enterprise/administer/distributed-search/9.3/update-search-head-cluster-members/use-the-deployer-to-distribute-apps-and-configuration-updates). +- **Old version from `local/app.conf`:** a local override is masking the package. Have the cluster administrator back up the named file and remove only the stale `[launcher] version` override through the appropriate cluster configuration procedure. Also inspect the deployer's staged `local/app.conf` so later pushes cannot reintroduce it. Removing it only on the deployer will not clear an existing member-local override; a new package cannot override that setting either. Preserve other local settings. +- **Correct version on disk, old version in Splunk Web:** verify which member served the page, complete any required cluster rolling restart, then reload Splunk Web. Manual changes to `app.conf` require a Splunk restart. [Splunk app.conf reference](https://help.splunk.com/en/splunk-enterprise/administer/admin-manual/10.0/configuration-file-reference/10.0.3-configuration-file-reference/app.conf). + +Keep release version metadata in `default/app.conf`; do not copy the whole file into `local/app.conf` to customize another setting. Release packages exclude `local/` and never rewrite cluster-local overrides automatically. + ## Workspace setup The setup wizard creates the first workspace. Select **Create roles in Splunk** to provision: diff --git a/README.md b/README.md index b1360d0..333a23f 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,17 @@ ActionStack is an AI-driven project attempting to build something useful for the - Team workspaces with access controlled by Splunk roles. - Automation catalog with search, categories, favorites, and pagination. - Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections. +- Regex validation supports patterns up to 2,048 characters and matches the entire field value. Use `(?x)` when formatting a pattern across lines. - Drafts, publishing, version history, cloning, and recoverable form deletion. - Reusable workspace categories and JSON form export/import. - Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks. -- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data. +- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, and separate groups for each returned action result’s summary and data. Actions with both successful and failed results show Mixed results alongside SOAR’s reported run status. Result grouping does not depend on input or completion order. - Optional receipt timeline and JSON receipt / CSV submission exports for audit review. - Optional retention for delivered submissions, with SOAR events retained. - Light, dark, and system themes. +Submission and receipt group counts reflect **SOAR run statuses**, which can differ from individual action-result outcomes. Receipts read additional result pages within a five-request budget and display up to five result details per action. When coverage is incomplete, counts are marked partial and the action shows **Results incomplete** unless mixed outcomes are already confirmed. Open SOAR for the complete results. + ## Installation ActionStack runs on a standalone Splunk Enterprise search head or a search head cluster, with KV Store enabled. diff --git a/frontend/src/ActionResults.tsx b/frontend/src/ActionResults.tsx new file mode 100644 index 0000000..f16ce63 --- /dev/null +++ b/frontend/src/ActionResults.tsx @@ -0,0 +1,87 @@ +import { keyedActionResults } from "./action-results.js"; +import { runStatusLabel } from "./run-status.js"; +import type { RunGroup } from "./types"; + +function resultText(value: unknown) { + return typeof value === "string" ? value : JSON.stringify(value, null, 2); +} + +export function ActionResults({ run }: { run: RunGroup["items"][number] }) { + const results = run.summaries || []; + const partial = run.result_counts_complete === false; + if (!results.length && !partial) return null; + const counts = + run.result_counts || + results.reduce>((outcomes, result) => { + outcomes[result.status] = (outcomes[result.status] || 0) + 1; + return outcomes; + }, {}); + const total = Object.values(counts).reduce((sum, n) => sum + n, 0); + return ( +
+

+ {partial ? "Partial action result counts" : "Reported action results"}:{" "} + {Object.entries(counts) + .filter(([, count]) => count > 0) + .map( + ([status, count]) => + `${count} ${runStatusLabel(status).toLowerCase()}`, + ) + .join(" · ") || "No results loaded"} +

+ {partial && ( +

+ Loaded {total} + {run.result_total != null ? ` of ${run.result_total}` : ""} results. + Counts are incomplete; additional failures may be unreported. Open + SOAR for all outcomes. +

+ )} + {keyedActionResults(results).map(({ result, key }, i) => ( +
+
+
+ Action result {i + 1} + App run #{result.app_run_id} +
+ + {runStatusLabel(result.status)} + +
+ {result.summary !== undefined && ( +
+ Action summary +
{resultText(result.summary)}
+
+ )} + {result.data !== undefined && ( +
+ Result data +
{resultText(result.data)}
+ {result.data_truncated && ( +

+ Showing a limited preview. Open SOAR for complete results. +

+ )} +
+ )} + {result.summary === undefined && result.data === undefined && ( +

+ No summary or result data reported for this result. +

+ )} +
+ ))} + {total > results.length && ( +

+ Showing details for {results.length} of {total} reported action + results. Open SOAR for all results. +

+ )} +
+ ); +} diff --git a/frontend/src/AutomationActivity.tsx b/frontend/src/AutomationActivity.tsx index 3438596..70f6c2b 100644 --- a/frontend/src/AutomationActivity.tsx +++ b/frontend/src/AutomationActivity.tsx @@ -1,12 +1,14 @@ -import { Fragment, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { RefreshCw } from "lucide-react"; import { RunCounts } from "./RunCounts"; +import { ActionResults } from "./ActionResults"; import { api } from "./api"; import { ReceiptTimeline } from "./ReceiptTimeline"; +import { runStatusLabel } from "./run-status.js"; import type { ActivitySnapshot } from "./receipt-audit.js"; import type { Activity, RunGroup, Submission } from "./types"; -function Runs({ title, group }: { title: string; group: RunGroup }) { +export function Runs({ title, group }: { title: string; group: RunGroup }) { return (

@@ -30,57 +32,29 @@ function Runs({ title, group }: { title: string; group: RunGroup }) { )} {group.items.map((run) => (
-
- {run.name} - - {run.block_type || `Run #${run.id}`} - {run.action && run.action !== run.name - ? ` · ${run.action}` - : ""} - {run.playbook_run_id - ? ` · Playbook run #${run.playbook_run_id}` - : ""} - - {run.summaries?.map((s, i) => ( - - {s.summary !== undefined && ( -
- - Action summary · {s.status} · app run #{s.app_run_id} - -
-                          {typeof s.summary === "string"
-                            ? s.summary
-                            : JSON.stringify(s.summary, null, 2)}
-                        
-
- )} - {s.data !== undefined && ( -
- - Result data · {s.status} · app run #{s.app_run_id} - -
-                          {typeof s.data === "string"
-                            ? s.data
-                            : JSON.stringify(s.data, null, 2)}
-                        
- {s.data_truncated && ( -

- Showing a limited preview. Open SOAR for complete - results. -

- )} -
- )} -
- ))} +
+
+ {run.name} + + {run.block_type || `Run #${run.id}`} + {run.action && run.action !== run.name + ? ` · ${run.action}` + : ""} + {run.playbook_run_id + ? ` · Playbook run #${run.playbook_run_id}` + : ""} + + {run.reported_status && ( + + SOAR run status: {runStatusLabel(run.reported_status)} + + )} +
+ + {runStatusLabel(run.status)} +
- - {run.status === "success" - ? "Succeeded" - : run.status.charAt(0).toUpperCase() + run.status.slice(1)} - +
))} {group.notice &&

{group.notice}

} diff --git a/frontend/src/FieldValidation.tsx b/frontend/src/FieldValidation.tsx index 7696008..4a479c6 100644 --- a/frontend/src/FieldValidation.tsx +++ b/frontend/src/FieldValidation.tsx @@ -70,27 +70,40 @@ export function FieldValidation({ {!["ip_address", "domain", "sha1", "sha256"].includes(r.operator) && ( )} diff --git a/frontend/src/ReceiptTimeline.tsx b/frontend/src/ReceiptTimeline.tsx index 2617192..569c133 100644 --- a/frontend/src/ReceiptTimeline.tsx +++ b/frontend/src/ReceiptTimeline.tsx @@ -1,4 +1,5 @@ import { receiptTimeline } from "./receipt-audit.js"; +import { runStatusLabel } from "./run-status.js"; import type { Activity, Submission } from "./types"; export function ReceiptTimeline({ @@ -62,10 +63,7 @@ export function ReceiptTimeline({ {row.status === "submitted" ? "Delivered" - : row.status === "success" - ? "Succeeded" - : row.status.charAt(0).toUpperCase() + - row.status.slice(1).replaceAll("_", " ")} + : runStatusLabel(row.status)}
diff --git a/frontend/src/RunCounts.tsx b/frontend/src/RunCounts.tsx index beca6ab..2d52c88 100644 --- a/frontend/src/RunCounts.tsx +++ b/frontend/src/RunCounts.tsx @@ -2,7 +2,7 @@ import type { RunGroup } from "./types"; export type CountGroup = Pick< RunGroup, - "counts" | "total" | "truncated" | "error" + "counts" | "counts_basis" | "total" | "truncated" | "error" >; export function RunCounts({ @@ -27,6 +27,11 @@ export function RunCounts({ {group.total} total + {group.counts_basis === "soar_run_status" && ( + + SOAR run statuses + + )} {Object.entries(group.counts || {}).map( ([status, count]) => count > 0 && ( @@ -39,13 +44,15 @@ export function RunCounts({ ? "✓" : status === "failed" ? "✕" - : status === "running" - ? "↻" - : status === "pending" - ? "◷" - : status === "cancelled" - ? "−" - : "?"}{" "} + : status === "mixed" + ? "◐" + : status === "running" + ? "↻" + : status === "pending" + ? "◷" + : status === "cancelled" + ? "−" + : "?"}{" "} {count} {status} diff --git a/frontend/src/action-results.js b/frontend/src/action-results.js new file mode 100644 index 0000000..2ddc341 --- /dev/null +++ b/frontend/src/action-results.js @@ -0,0 +1,19 @@ +/** Keep disclosure state with the returned result when asynchronous results + * arrive in a different order. SOAR does not always report an action-result ID. + * Identical results remain separate groups rather than being deduplicated. + * @param {import('./types').ActionResult[]} results */ +export function keyedActionResults(results) { + const occurrences = new Map(); + return results.map((result) => { + const signature = JSON.stringify({ + app_run_id: result.app_run_id, + status: result.status, + summary: result.summary, + data: result.data, + data_truncated: result.data_truncated, + }); + const occurrence = occurrences.get(signature) || 0; + occurrences.set(signature, occurrence + 1); + return { result, key: `${signature}:${occurrence}` }; + }); +} diff --git a/frontend/src/receipt-audit.js b/frontend/src/receipt-audit.js index 60375ea..6c6042f 100644 --- a/frontend/src/receipt-audit.js +++ b/frontend/src/receipt-audit.js @@ -1,3 +1,4 @@ +import { runStatusLabel } from "./run-status.js"; /** @typedef {import('./types').Submission} Submission */ /** @typedef {import('./types').Activity} Activity */ /** @typedef {{data: Activity | null, error: string, loading: boolean}} ActivitySnapshot */ @@ -43,7 +44,7 @@ export function receiptTimeline(submission, activity) { id: `${key}:${run.id}`, kind: run.block_type || kind, name: run.name, - detail: `${key === "blocks" ? "Block" : "Run"} #${run.id}${run.playbook_run_id ? ` · Playbook run #${run.playbook_run_id}` : ""}`, + detail: `${key === "blocks" ? "Block" : "Run"} #${run.id}${run.playbook_run_id ? ` · Playbook run #${run.playbook_run_id}` : ""}${run.reported_status ? ` · SOAR run status: ${runStatusLabel(run.reported_status)}` : ""}`, status: run.status, at: timestamp(run.updated_at), }); diff --git a/frontend/src/run-status.js b/frontend/src/run-status.js new file mode 100644 index 0000000..0e2d86c --- /dev/null +++ b/frontend/src/run-status.js @@ -0,0 +1,7 @@ +/** @param {string} status */ +export function runStatusLabel(status) { + if (status === "success") return "Succeeded"; + if (status === "mixed") return "Mixed results"; + if (status === "incomplete") return "Results incomplete"; + return status.charAt(0).toUpperCase() + status.slice(1).replaceAll("_", " "); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 10dcae6..8961d81 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2388,13 +2388,41 @@ margin: 0 0 8px; } .actionstack-app .activity-run { - display: flex; - align-items: center; - justify-content: space-between; + display: grid; gap: 12px; border-top: 1px solid var(--line); padding: 10px 0; } +.actionstack-app .activity-run-heading, +.actionstack-app .actionstack-result-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.actionstack-app .activity-run-heading > div, +.actionstack-app .actionstack-result-heading > div { + min-width: 0; +} +.actionstack-app .actionstack-result-counts { + margin: 0 0 10px; + font-size: 12px; + color: var(--muted); +} +.actionstack-app .actionstack-action-result { + min-width: 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} +.actionstack-app .actionstack-action-result + .actionstack-action-result { + margin-top: 12px; +} +.actionstack-app .actionstack-action-result p { + margin-bottom: 0; + font-size: 12px; +} .actionstack-app .activity-run b { font-size: 13px; overflow-wrap: anywhere; @@ -2414,6 +2442,11 @@ color: #a6dcc9; background: #19322d; } +.actionstack-app .run-mixed, +.actionstack-app .run-incomplete { + color: #f2d397; + background: #3b3020; +} .actionstack-app .run-failed, .actionstack-app .activity-error { color: #edaeb7; @@ -3547,6 +3580,11 @@ color: #943042; background: #f9e2e7; } +.actionstack-app[data-theme="light"] .run-mixed, +.actionstack-app[data-theme="light"] .run-incomplete { + color: #765019; + background: #faedcf; +} .actionstack-app[data-theme="light"] :is( th, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2c887a8..808b303 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -113,8 +113,16 @@ export type Activity = { actions: RunGroup; blocks?: RunGroup; }; +export type ActionResult = { + app_run_id: number; + status: string; + summary?: unknown; + data?: unknown; + data_truncated?: boolean; +}; export type RunGroup = { counts?: Record | null; + counts_basis?: "soar_run_status"; notice?: string; summary_error?: string; summary_truncated?: boolean; @@ -124,13 +132,12 @@ export type RunGroup = { action?: string; block_type?: string; status: string; - summaries?: { - app_run_id: number; - status: string; - summary?: unknown; - data?: unknown; - data_truncated?: boolean; - }[]; + reported_status?: string; + result_counts?: Record; + result_counts_complete?: boolean; + result_total?: number | null; + // Legacy API name: each entry represents one returned action_result. + summaries?: ActionResult[]; playbook_run_id: number | null; updated_at: string | null; }[]; diff --git a/package-lock.json b/package-lock.json index f7c14c0..6d06a3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "splunk-actionstack", - "version": "0.5.9", + "version": "0.5.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "splunk-actionstack", - "version": "0.5.9", + "version": "0.5.10", "dependencies": { "lucide-react": "^0.577.0", "react": "^19.2.0", diff --git a/package.json b/package.json index d153c5e..1748849 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splunk-actionstack", - "version": "0.5.9", + "version": "0.5.10", "private": true, "type": "module", "scripts": { diff --git a/scripts/dev_server.py b/scripts/dev_server.py index 9a9b00a..e79a3c5 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -57,7 +57,7 @@ class DemoSoar: def __init__(self,store): self.store=store def labels(self): return ['automation_requests','events','phishing','service_requests'] def activity(self,container_id,details=True): - return {key:{'items':[],'total':0,'truncated':False,'error':None,'counts':dict.fromkeys(['success','failed','running','pending','cancelled','unknown'],0)} for key in (['playbooks','actions','blocks'] if details else ['playbooks','actions'])} + return {key:{'items':[],'total':0,'truncated':False,'error':None,'counts_basis':'soar_run_status','counts':dict.fromkeys(['success','failed','mixed','running','pending','cancelled','unknown'],0)} for key in (['playbooks','actions','blocks'] if details else ['playbooks','actions'])} def ensure(self,kind,payload): key=payload['source_data_identifier']; existing=self.store.get('remote_'+kind,key) if existing: return existing['id'] diff --git a/scripts/package_app.py b/scripts/package_app.py index 4b3d485..72e7607 100644 --- a/scripts/package_app.py +++ b/scripts/package_app.py @@ -11,6 +11,8 @@ ROOT=Path(__file__).resolve().parents[1] APP=ROOT/'splunk_actionstack' version=json.loads((ROOT/'package.json').read_text())['version'] +lock=json.loads((ROOT/'package-lock.json').read_text()) +assert lock['version']==version and lock['packages']['']['version']==version, 'Lockfile versions differ from package.json' conf=configparser.ConfigParser(); conf.read(APP/'default'/'app.conf') assert conf['launcher']['version']==version, 'Package versions differ' assert conf.getboolean('install','is_configured',fallback=False) is False, 'Do not package completed setup state' diff --git a/splunk_actionstack/bin/actionstack/run_activity.py b/splunk_actionstack/bin/actionstack/run_activity.py index 816e3e4..b79cc71 100644 --- a/splunk_actionstack/bin/actionstack/run_activity.py +++ b/splunk_actionstack/bin/actionstack/run_activity.py @@ -3,7 +3,7 @@ import json import re -STATUSES = ('success', 'failed', 'running', 'pending', 'cancelled', 'unknown') +STATUSES = ('success', 'failed', 'mixed', 'running', 'pending', 'cancelled', 'unknown') def run_status(value): @@ -14,7 +14,9 @@ def run_status(value): def counts(group): if group.get('error'): return None result = dict.fromkeys(STATUSES, 0) - for item in group['items']: result[run_status(item.get('status'))] += 1 + # Group badges always count SOAR's run status, in both list and receipt. + # Per-result outcomes (including Mixed results) are shown separately. + for item in group['items']: result[run_status(item.get('reported_status',item.get('status')))] += 1 return result diff --git a/splunk_actionstack/bin/actionstack/service.py b/splunk_actionstack/bin/actionstack/service.py index 2c3b844..21834b1 100644 --- a/splunk_actionstack/bin/actionstack/service.py +++ b/splunk_actionstack/bin/actionstack/service.py @@ -339,7 +339,7 @@ def activity(self,actor,sid,summary=False): if summary: remote.timeout=min(getattr(remote,'timeout',5),5) result=remote.activity(record['container_id'],details=False) - result={key:{k:group.get(k) for k in ['total','counts','truncated','error']} for key,group in result.items() if key in ['playbooks','actions']} + result={key:{k:group.get(k) for k in ['total','counts','counts_basis','truncated','error']} for key,group in result.items() if key in ['playbooks','actions']} else: result=remote.activity(record['container_id']) return dict(result,checked_at=utcnow(),automation_enabled=bool(record['artifact_payload'].get('run_automation')),demo=self.demo) diff --git a/splunk_actionstack/bin/actionstack/soar.py b/splunk_actionstack/bin/actionstack/soar.py index d243022..b6212a9 100644 --- a/splunk_actionstack/bin/actionstack/soar.py +++ b/splunk_actionstack/bin/actionstack/soar.py @@ -33,6 +33,8 @@ def load_system_ca_bundles(context): MAX_RESPONSE=4*1024*1024 MAX_ACTION_DATA_PREVIEW=16000 MAX_ACTIVITY_DATA=256000 +ACTION_RESULT_PAGE_SIZE=25 +MAX_ACTION_RESULT_READS=5 def action_data_preview(value,token): """Bound and redact connector data before exposing it in a receipt.""" @@ -200,10 +202,21 @@ def safe(value,limit=180): summaries=self.action_summaries(container_id) for row in actions['items']: row['summaries']=summaries['items'].get(row['id'],[]) + row['result_counts']=summaries['counts'].get(row['id'],{}) + row['result_counts_complete']=not summaries['app_runs_truncated'] and row['id'] not in summaries['incomplete'] + row['result_total']=None if summaries['app_runs_truncated'] else summaries['totals'].get(row['id']) + if row['result_counts'].get('success') and row['result_counts'].get('failed'): + row['reported_status']=row['status'] + row['status']='mixed' + elif not row['result_counts_complete']: + row['reported_status']=row['status'] + row['status']='incomplete' actions['summary_truncated']=summaries['truncated'] except Error: actions['summary_error']='Action results are unavailable. Check SOAR app-run read permissions.' - for group in output.values(): group['counts']=counts(group) + for group in output.values(): + group['counts']=counts(group) + group['counts_basis']='soar_run_status' if details: blocks=[]; unavailable=False; limited=output['playbooks']['truncated'] or len(reports)>3 old_timeout=self.timeout @@ -226,11 +239,14 @@ def safe(value,limit=180): return output def action_summaries(self,container_id): - # Keep the summaries response key for compatibility; each result may - # contain a summary, data, or both inside app_run.result_data. + # The legacy summaries key contains one projection per action_result + # returned in app_run.result_data or the action_result endpoint. Keep + # its status, summary and data together; never join by input position. result=self.call('GET','app_run',query={'_filter_container':container_id,'page':0,'page_size':100,'sort':'id','order':'desc'}) if not isinstance(result,dict) or not isinstance(result.get('data'),list) or type(result.get('count')) is not int or result['count']100: raise Error(502,'Invalid app run list.') - items={}; truncated=result['count']>len(result['data']); fetched=0; data_budget=MAX_ACTIVITY_DATA + items={}; result_counts={}; totals={}; incomplete=set() + app_runs_truncated=result['count']>len(result['data']) + truncated=app_runs_truncated; fetched=0; data_budget=MAX_ACTIVITY_DATA def clean(value,depth=0): if depth>3: return '[truncated]' if isinstance(value,str): return response_detail({'message':value},self.token)[:500] @@ -247,24 +263,43 @@ def clean(value,depth=0): for row in result['data']: if not isinstance(row,dict) or type(row.get('container')) is not int or row['container']!=container_id or type(row.get('id')) is not int or type(row.get('action_run')) is not int: raise Error(502,'App run does not match the event.') target=items.setdefault(row['action_run'],[]) + outcomes=result_counts.setdefault(row['action_run'],{}) data=row.get('result_data') if data is None: - if fetched>=5: truncated=True; continue - fetched+=1 - prior_timeout=self.timeout - try: - self.timeout=min(self.timeout,5) - detail=self.call('GET','app_run/'+str(row['id'])+'/action_result',query={'page':0,'page_size':5}) - finally: self.timeout=prior_timeout - if not isinstance(detail,dict) or not isinstance(detail.get('data'),list) or type(detail.get('count')) is not int or detail['count']5: raise Error(502,'Invalid action results.') - data=detail['data']; truncated=truncated or detail['count']>len(data) + data=[]; page=0; total=None; complete=False + while fetchedACTION_RESULT_PAGE_SIZE: raise Error(502,'Invalid action results.') + # A changing total means pages are not a complete snapshot. + if total is not None and total!=detail['count']: + total=None + break + total=detail['count']; data.extend(detail['data']) + if len(data)==total: complete=True; break + if len(data)>total: raise Error(502,'Invalid action results.') + if len(detail['data'])=5: truncated=True; break - status=entry.get('status') - projected={'app_run_id':row['id'],'status':'failed' if status=='failure' else status if status in ['success','failed','pending','running'] else 'unknown'} + status=run_status(entry.get('status')) + # Count every reported result, including those beyond the preview + # limit and failures that have no summary or result data. + outcomes[status]=outcomes.get(status,0)+1 + if len(target)>=5: truncated=True; continue + projected={'app_run_id':row['id'],'status':status} if entry.get('summary'): summary=clean(entry['summary']) if len(json.dumps(summary))>4000: summary={'notice':'Summary exceeds display limit. Open SOAR for the full summary.'}; truncated=True @@ -281,7 +316,7 @@ def clean(value,depth=0): projected['data_truncated']=limited truncated=truncated or limited target.append(projected) - return {'items':items,'truncated':truncated} + return {'items':items,'counts':result_counts,'totals':totals,'incomplete':sorted(incomplete),'app_runs_truncated':app_runs_truncated,'truncated':truncated} def lookup(self,kind,payload): query={'_filter_source_data_identifier':json.dumps(payload['source_data_identifier']),'page_size':100} diff --git a/splunk_actionstack/bin/actionstack/validation_policies.py b/splunk_actionstack/bin/actionstack/validation_policies.py index 485c645..2f3b097 100644 --- a/splunk_actionstack/bin/actionstack/validation_policies.py +++ b/splunk_actionstack/bin/actionstack/validation_policies.py @@ -8,6 +8,8 @@ from pathlib import Path from .core import Error, Conflict, KEY, SLUG, require, clone, utcnow, visible, digest, approval_rule, DURATIONS +MAX_REGEX_LENGTH=2048 + def scalar(value): return (isinstance(value,(str,bool)) and (not isinstance(value,str) or len(value)<=200)) or (type(value) in [int,float] and math.isfinite(value)) @@ -21,7 +23,7 @@ def validate_rules(rules,fields=None): if r['operator'] in ['equals','not_equals','min','max'] and ('value' not in r or not scalar(r['value'])): raise Error(400,'The validation rule needs a text, boolean or finite numeric value.') if r['operator'] in ['min','max'] and type(r['value']) not in [int,float]: raise Error(400,'Minimum and maximum rules require a number.') if r['operator']=='regex': - if not isinstance(r.get('value'),str) or not 1<=len(r['value'])<=512: raise Error(400,'Enter a regex pattern of 1–512 characters.') + if not isinstance(r.get('value'),str) or not 1<=len(r['value'])<=MAX_REGEX_LENGTH: raise Error(400,f'Enter a regex pattern of 1–{MAX_REGEX_LENGTH:,} characters.') try: re.compile(r['value']) except (re.error,RecursionError,OverflowError): raise Error(400,'Invalid regex pattern for '+r['field']+'.') condition=r.get('when') diff --git a/splunk_actionstack/default/app.conf b/splunk_actionstack/default/app.conf index 8b40f62..9b733b3 100644 --- a/splunk_actionstack/default/app.conf +++ b/splunk_actionstack/default/app.conf @@ -8,7 +8,7 @@ label = ActionStack [launcher] author = ActionStack -version = 0.5.9 +version = 0.5.10 description = Role-aware forms and reliable event submission to Splunk SOAR. [package] diff --git a/tests/action-results.test.mjs b/tests/action-results.test.mjs new file mode 100644 index 0000000..c3bd05e --- /dev/null +++ b/tests/action-results.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { keyedActionResults } from '../frontend/src/action-results.js'; + +const first={app_run_id:9,status:'success',summary:{task_id:'A'},data:[{task_id:'A'}]}; +const second={app_run_id:9,status:'failed',summary:{task_id:'B'},data:[]}; + +test('reordered action results keep their own summary, data, and disclosure identity',()=>{ + const original=keyedActionResults([first,second]); + const reordered=keyedActionResults([second,first]); + assert.deepEqual(reordered,[original[1],original[0]]); + assert.equal(reordered[0].result,second); + assert.equal(reordered[1].result,first); +}); + +test('new asynchronous results do not shift existing result identities',()=>{ + const original=keyedActionResults([first,second]); + const updated=keyedActionResults([{app_run_id:10,status:'failed',data:[]},first,second]); + assert.deepEqual(updated.slice(1),original); +}); + +test('identical results remain separate and app runs are never merged',()=>{ + const rows=keyedActionResults([first,{...first},{...first,app_run_id:10}]); + assert.equal(rows.length,3); + assert.equal(new Set(rows.map(row=>row.key)).size,3); +}); diff --git a/tests/receipt-audit.test.mjs b/tests/receipt-audit.test.mjs index 0d712ab..c1b5827 100644 --- a/tests/receipt-audit.test.mjs +++ b/tests/receipt-audit.test.mjs @@ -31,6 +31,18 @@ test('invalid and absent dates do not become fabricated event times',()=>{ assert.equal(result.length,2);assert.equal(result[1].at,null); assert.equal(result[1].name,'Latest delivery update'); }); +test('mixed action results retain the SOAR status and result groups in timelines and exports',()=>{ + const action={...activity.actions.items[0],status:'mixed',reported_status:'success',result_counts:{success:1,failed:1},summaries:[ + {app_run_id:3,status:'success',summary:{accepted:1},data:[{task_id:'alice-task'}]}, + {app_run_id:3,status:'failed',data:[]}, + ]}; + const mixed={...activity,actions:group([action])}; + const result=receiptExport(submission,{data:mixed,error:'',loading:false},meta); + const timeline=result.timeline.find(row=>row.id==='actions:2'); + assert.equal(timeline.status,'mixed'); + assert.match(timeline.detail,/SOAR run status: Succeeded/); + assert.deepEqual(result.activity.actions.items[0],action); +}); test('receipt export preserves submitted values, result limits and stale-read failures, but excludes connection internals',()=>{ const source={...submission,connection:{token:'secret'},actor:{session:'secret'},container_payload:{private:'secret'},fingerprint:'secret'}; const result=receiptExport(source,{data:activity,error:'Refresh failed',loading:false},meta); @@ -41,6 +53,14 @@ test('receipt export preserves submitted values, result limits and stale-read fa assert.equal(result.receipt.form_snapshot.version,2); assert.ok(!JSON.stringify(result).includes('secret')); }); +test('incomplete outcome coverage is retained in receipt exports and timeline',()=>{ + const action={...activity.actions.items[0],status:'incomplete',reported_status:'success',result_counts_complete:false,result_counts:{success:125},result_total:126}; + const snapshot={...activity,actions:{...group([action]),counts_basis:'soar_run_status',counts:{success:1},summary_truncated:true}}; + const result=receiptExport(submission,{data:snapshot,error:'',loading:false},meta); + assert.equal(result.activity.actions.items[0].result_counts_complete,false); + assert.equal(result.activity.actions.items[0].result_total,126); + assert.equal(result.timeline.find(row=>row.id==='actions:2').status,'incomplete'); +}); test('unavailable activity is explicit and does not prevent a delivery receipt export',()=>{ const result=receiptExport(submission,{data:null,error:'Access unavailable',loading:false},meta); assert.equal(result.activity,null);assert.equal(result.activity_error,'Access unavailable');assert.equal(result.timeline.length,2); diff --git a/tests/test_field_catalog.py b/tests/test_field_catalog.py index 6653783..dc016c6 100644 --- a/tests/test_field_catalog.py +++ b/tests/test_field_catalog.py @@ -17,6 +17,16 @@ def check(op,value):return {'operator':op,'value':value,'message':'Choose a vali def definition(field): f=seed_form();f['mapping'].pop('policy');f['fields']=[field];return f +def ipv6_pattern(): + # Expanded and compressed hexadecimal IPv6, without positional matching. + h='[0-9a-fA-F]{1,4}' + alternatives=[f'(?:{h}:){{7}}{h}',f'::(?:{h}(?::{h}){{0,6}})?'] + for left in range(1,8): + prefix=':'.join([h]*left)+'::' + right=7-left + alternatives.append(prefix+(f'(?:{h}(?::{h}){{0,{right-1}}})?' if right else '')) + return '(?:'+'|'.join(alternatives)+')' + class FieldChecks(unittest.TestCase): def validate(self,kind,rules,value): f=validate_definition(definition(simple_field(kind,rules)),fx.ROLES) @@ -41,8 +51,17 @@ def test_list_shape_uniqueness_and_limit(self): for bad in ['alice',['a','a'],['a',' a '],[''],[4],['a'*201],[str(n) for n in range(26)]]: with self.subTest(value=bad),self.assertRaises(Error):self.validate('text_list',[],bad) def test_invalid_or_unsafe_definitions(self): - for rule in [check('regex','['),check('regex','a'*513),check('like','a'*201),check('required','x'),check('bogus','x'),dict(check('equals','x'),field='other')]: + for rule in [check('regex','['),check('regex','a'*2049),check('like','a'*201),check('required','x'),check('bogus','x'),dict(check('equals','x'),field='other')]: with self.subTest(rule=rule),self.assertRaises(Error):validate_definition(definition(simple_field(rules=[rule])),fx.ROLES) + def test_long_ipv6_regex_and_length_boundary(self): + pattern=ipv6_pattern() + self.assertGreater(len(pattern),512) + self.assertLess(len(pattern),2048) + for value in ['::','::1','2001:db8::1','fe80::abcd:1234','1:2:3:4:5:6:7:8','1:2:3:4:5:6:7::']: + self.validate('text',[check('regex',pattern)],value) + for value in ['2001:db8:::1','12345::1','1:2:3:4:5:6:7:8:9','not-an-ip','192.0.2.1']: + with self.subTest(value=value),self.assertRaises(Error):self.validate('text',[check('regex',pattern)],value) + validate_definition(definition(simple_field(rules=[check('regex','a'*2048)])),fx.ROLES) def test_hidden_and_optional_fields_are_skipped(self): f=definition(simple_field(rules=[check('regex','X')]));f['fields'][0]['required']=False validate_field_inputs(f,{}) @@ -58,6 +77,17 @@ def test_arrays_reach_soar_as_structured_inputs(self): class LifecycleTests(unittest.TestCase): setUp=fx.ServiceTests.setUp tearDown=fx.ServiceTests.tearDown + def test_long_regex_survives_preview_publish_and_submission(self): + pattern=ipv6_pattern(); form=definition(simple_field(rules=[check('regex',pattern)])) + self.svc.preview_validation(fx.ADMIN,{'form':form,'inputs':{'target':'2001:db8::1'}}) + with self.assertRaises(Error):self.svc.preview_validation(fx.ADMIN,{'form':form,'inputs':{'target':'invalid'}}) + saved=self.svc.save_form(fx.ADMIN,{'form':form,'expected_revision':1},True) + self.assertEqual(self.svc.published(form['id'])['fields'][0]['validation'][0]['value'],pattern) + body={'form_id':form['id'],'form_version':saved['version'],'inputs':{'target':'2001:db8::1'},'idempotency_key':'long-regex-valid'} + receipt=self.svc.submit(self.user,body) + self.assertEqual(receipt['status'],'submitted') + with self.assertRaises(Error):self.svc.submit(self.user,dict(body,inputs={'target':'invalid'},idempotency_key='long-regex-invalid')) + self.assertEqual(len(self.store.list('submissions')),1) def test_checkbox_defaults_survive_publish_and_submission(self): revision=1 for default in [True,False]: diff --git a/tests/test_run_activity.py b/tests/test_run_activity.py index b1ef816..594c62c 100644 --- a/tests/test_run_activity.py +++ b/tests/test_run_activity.py @@ -3,10 +3,152 @@ from unittest.mock import Mock, patch import test_pages as fx from actionstack.core import Error,seed_form,validate_definition -from actionstack.soar import Soar +from actionstack.soar import Soar, ACTION_RESULT_PAGE_SIZE, MAX_ACTION_RESULT_READS from actionstack.run_activity import block_rows,utility_rows class RunActivityTests(unittest.TestCase): + def action_activity(self, app_runs, status='success'): + remote=Soar({'soar_url':'https://soar.example.test'},'secret-token') + remote.call=Mock(side_effect=[{'count':0,'data':[]}, + {'count':1,'data':[{'id':4,'container':17,'name':'Sign out users','status':status}]}, + {'count':len(app_runs),'data':app_runs}]) + return remote.activity(17)['actions'] + + def test_each_action_result_keeps_its_summary_data_and_status_when_reordered(self): + entries=[ + {'parameter':{'user':'alice@example.test'},'status':'success','summary':{'task_id':'task-alice','accepted':1},'data':[{'task_id':'task-alice'}]}, + {'parameter':{'user':'bob@example.test'},'status':'failure','summary':{'task_id':'task-bob','accepted':0},'data':[]}, + ] + for ordered in [entries,entries[::-1]]: + group=self.action_activity([{'id':9,'container':17,'action_run':4,'result_data':ordered}]) + run=group['items'][0] + self.assertEqual(run['status'],'mixed') + self.assertEqual(run['reported_status'],'success') + self.assertEqual(run['result_counts'],{'success':1,'failed':1}) + self.assertEqual(group['counts']['mixed'],0) + self.assertEqual(group['counts']['success'],1) + self.assertEqual(group['counts_basis'],'soar_run_status') + self.assertEqual(run['summaries'],[{'app_run_id':9,'status':'failed' if result['status']=='failure' else result['status'], + 'summary':result['summary'],'data':result['data'],'data_truncated':False} for result in ordered]) + self.assertNotIn('parameter',json.dumps(run)) + + def test_async_app_runs_remain_attached_to_their_action_run(self): + remote=Soar({'soar_url':'https://soar.example.test'},'') + actions=[{'id':4,'container':17,'name':'First action','status':'success'}, + {'id':5,'container':17,'name':'Second action','status':'success'}] + result_a={'status':'success','summary':{'task':'A'},'data':[{'task':'A'}]} + result_b={'status':'failure','summary':{'task':'B'},'data':[{'task':'B'}]} + apps=[{'id':90,'container':17,'action_run':5,'result_data':[result_b]}, + {'id':91,'container':17,'action_run':4,'result_data':[result_a]}] + for ordered in [apps,apps[::-1]]: + remote.call=Mock(side_effect=[{'count':0,'data':[]},{'count':2,'data':actions},{'count':2,'data':ordered}]) + runs={run['id']:run for run in remote.activity(17)['actions']['items']} + self.assertEqual(runs[4]['summaries'][0]['summary'],result_a['summary']) + self.assertEqual(runs[4]['summaries'][0]['data'],result_a['data']) + self.assertEqual(runs[5]['summaries'][0]['summary'],result_b['summary']) + self.assertEqual(runs[5]['summaries'][0]['data'],result_b['data']) + + def test_fallback_keeps_reversed_results_and_empty_payloads_separate(self): + remote=Soar({'soar_url':'https://soar.example.test'},'') + entries=[{'status':'failure','data':[]}, {'status':'success','summary':{'task':'A'},'data':[{'task':'A'}]}] + remote.call=Mock(side_effect=[{'count':1,'data':[{'id':9,'container':17,'action_run':4}]}, {'count':2,'data':entries}]) + results=remote.action_summaries(17)['items'][4] + self.assertEqual(results[0],{'app_run_id':9,'status':'failed','data':[],'data_truncated':False}) + self.assertEqual(results[1]['summary'],entries[1]['summary']) + self.assertEqual(results[1]['data'],entries[1]['data']) + + def test_failed_items_beyond_preview_and_across_app_runs_still_mark_mixed(self): + rows=[{'id':9,'container':17,'action_run':4,'result_data':[{'status':'success','data':[]}]*5}, + {'id':10,'container':17,'action_run':4,'result_data':[{'status':'failure'}]*2}] + group=self.action_activity(rows) + run=group['items'][0] + self.assertEqual(run['status'],'mixed') + self.assertEqual(run['result_counts'],{'success':5,'failed':2}) + self.assertEqual(len(run['summaries']),5) + self.assertTrue(group['summary_truncated']) + rows[0]['result_data'] += rows.pop()['result_data'] + self.assertEqual(self.action_activity(rows)['items'][0]['result_counts'],run['result_counts']) + + def test_status_only_and_unknown_items_survive_without_fabricated_payloads(self): + group=self.action_activity([{'id':9,'container':17,'action_run':4,'result_data':[ + {'status':'failure'}, {'status':'cancelled'}, {'status':'future-status'}, + ]}],status='running') + run=group['items'][0] + self.assertEqual(run['status'],'running') + self.assertNotIn('reported_status',run) + self.assertEqual(run['result_counts'],{'failed':1,'cancelled':1,'unknown':1}) + self.assertEqual(run['summaries'],[{'app_run_id':9,'status':s} for s in ['failed','cancelled','unknown']]) + + def test_single_outcome_retains_the_reported_run_status(self): + for status in ['success','failed','running','pending','cancelled','unknown']: + with self.subTest(status=status): + group=self.action_activity([{'id':9,'container':17,'action_run':4,'result_data':[{'status':status,'data':[]}]}],status) + self.assertEqual(group['items'][0]['status'],status) + self.assertNotIn('reported_status',group['items'][0]) + + def paginated_activity(self,pages,app_count=1): + remote=Soar({'soar_url':'https://soar.example.test'},'') + remote.call=Mock(side_effect=[{'count':0,'data':[]}, + {'count':1,'data':[{'id':4,'container':17,'name':'Sign out users','status':'success'}]}, + {'count':app_count,'data':[{'id':9,'container':17,'action_run':4}]}]+pages) + return remote,remote.activity(17)['actions'] + + def test_paginated_failure_after_first_page_marks_mixed(self): + size=ACTION_RESULT_PAGE_SIZE + remote,group=self.paginated_activity([ + {'count':size+1,'data':[{'status':'success'}]*size}, + {'count':size+1,'data':[{'status':'failure'}]}]) + run=group['items'][0] + self.assertEqual(run['status'],'mixed') + self.assertEqual(run['result_counts'],{'success':size,'failed':1}) + self.assertTrue(run['result_counts_complete']) + self.assertEqual(run['result_total'],size+1) + self.assertEqual(len(run['summaries']),5) + self.assertEqual([c.kwargs['query']['page'] for c in remote.call.call_args_list[3:]],[0,1]) + self.assertEqual(remote.timeout,15) + + def test_exhausted_read_budget_does_not_claim_all_results_succeeded(self): + size=ACTION_RESULT_PAGE_SIZE; budget=MAX_ACTION_RESULT_READS + remote,group=self.paginated_activity([{'count':size*budget+1,'data':[{'status':'success'}]*size}]*budget) + run=group['items'][0] + self.assertEqual(remote.call.call_count,3+budget) + self.assertEqual(run['status'],'incomplete') + self.assertEqual(run['reported_status'],'success') + self.assertFalse(run['result_counts_complete']) + self.assertEqual(run['result_total'],size*budget+1) + self.assertEqual(run['result_counts'],{'success':size*budget}) + self.assertTrue(group['summary_truncated']) + + def test_short_or_changing_pages_are_marked_incomplete(self): + for pages in [ + [{'count':6,'data':[{'status':'success'}]*5}], + [{'count':26,'data':[{'status':'success'}]*25},{'count':27,'data':[{'status':'failure'}]*2}], + ]: + remote,group=self.paginated_activity(pages) + self.assertEqual(group['items'][0]['status'],'incomplete') + self.assertFalse(group['items'][0]['result_counts_complete']) + + def test_omitted_app_runs_do_not_claim_complete_outcomes(self): + _,group=self.paginated_activity([{'count':1,'data':[{'status':'success'}]}],app_count=2) + self.assertEqual(group['items'][0]['status'],'incomplete') + self.assertIsNone(group['items'][0]['result_total']) + + def test_inline_counts_and_paginated_counts_agree(self): + entries=[{'status':'success'}]*5+[{'status':'failure'}] + _,paged=self.paginated_activity([{'count':6,'data':entries}]) + inline=self.action_activity([{'id':9,'container':17,'action_run':4,'result_data':entries}]) + self.assertEqual(paged,inline) + + def test_table_and_receipt_count_the_same_soar_run_status(self): + detailed=self.action_activity([{'id':9,'container':17,'action_run':4,'result_data':[{'status':'success'},{'status':'failure'}]}]) + remote=Soar({'soar_url':'https://soar.example.test'},'') + remote.call=Mock(side_effect=[{'count':0,'data':[]},{'count':1,'data':[{'id':4,'container':17,'status':'success'}]}]) + summary=remote.activity(17,details=False)['actions'] + self.assertEqual(detailed['counts'],summary['counts']) + self.assertEqual(detailed['counts_basis'],summary['counts_basis']) + self.assertEqual(detailed['items'][0]['status'],'mixed') + self.assertEqual(remote.call.call_count,2) + def test_custom_names_status_counts_and_summary_reads_skip_details(self): remote=Soar({'soar_url':'https://soar.example.test'},'token') remote.call=Mock(side_effect=[{'count':1,'data':[{'id':2,'container':17,'playbook':3,'status':'running'}]}, @@ -15,7 +157,7 @@ def test_custom_names_status_counts_and_summary_reads_skip_details(self): self.assertEqual(remote.call.call_count,2) self.assertEqual(result['actions']['items'][0]['name'],'Scan workstation') self.assertEqual(result['actions']['items'][0]['action'],'run query') - self.assertEqual(result['actions']['counts'],dict(success=1,failed=1,running=1,pending=0,cancelled=0,unknown=1)) + self.assertEqual(result['actions']['counts'],dict(success=1,failed=1,mixed=0,running=1,pending=0,cancelled=0,unknown=1)) self.assertNotIn('blocks',result) def test_missing_status_group_is_not_zero_success(self): remote=Soar({'soar_url':'https://soar.example.test'},'token');remote.call=Mock(side_effect=Error(403,'denied')) @@ -59,13 +201,14 @@ class ActivitySummaryServiceTests(unittest.TestCase): tearDown=fx.ServiceTests.tearDown def test_summary_acl_and_payload_excludes_action_results(self): receipt=self.svc.submit(self.user,fx.ServiceTests.body(self));remote=Mock();remote.timeout=15 - remote.activity.return_value={'actions':{'items':[{'summaries':['private']}],'total':1,'counts':{'success':1},'truncated':False,'error':None},'playbooks':{'items':[],'total':0,'counts':{},'truncated':False,'error':None}} + remote.activity.return_value={'actions':{'items':[{'summaries':['private']}],'total':1,'counts':{'success':1},'counts_basis':'soar_run_status','truncated':False,'error':None},'playbooks':{'items':[],'total':0,'counts':{},'truncated':False,'error':None}} self.factory.reset_mock();self.factory.return_value=remote;self.factory.side_effect=None with self.assertRaises(Error):self.svc.activity(fx.actor('bob'),receipt['id'],True) self.factory.assert_not_called() result=self.svc.dispatch(self.user,'GET','/submissions/'+receipt['id']+'/activity-summary') remote.activity.assert_called_once_with(receipt['container_id'],details=False) self.assertEqual(remote.timeout,5);self.assertNotIn('private',json.dumps(result));self.assertNotIn('items',result['actions']) + self.assertEqual(result['actions']['counts_basis'],'soar_run_status') def test_summary_budget_does_not_consume_receipt_or_delivery_budget(self): receipt=self.svc.submit(self.user,fx.ServiceTests.body(self)) with patch('actionstack.service.time.time',return_value=6000): diff --git a/tests/test_unified_validation.py b/tests/test_unified_validation.py index a3136a7..c06c7e0 100644 --- a/tests/test_unified_validation.py +++ b/tests/test_unified_validation.py @@ -72,7 +72,7 @@ class RegexTests(unittest.TestCase): def test_fullmatch_flags_and_unicode(self): self.assertEqual(regex_matches([['INC-[0-9]+','INC-42'],['INC-[0-9]+','xINC-42'],['(?i)inc-[0-9]+','Inc-42'],['[é]+','éé']]),[True,False,True,True]) def test_invalid_patterns_and_nontext_fields_are_rejected_on_save(self): - for pattern in ['[','a{9999999999999}','a'*513,'']: + for pattern in ['[','a{9999999999999}','a'*2049,'']: with self.assertRaises(Error):validate_rules([regex_rule(pattern)]) with self.assertRaises(Error):validate_rules([regex_rule('.*')],[{'key':'ticket_reference','type':'number'}]) def test_condition_hidden_and_optional_values(self):