feat(test-catalog): add template creation and lead-approval workflow
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Leads get a Create Template button that adds directly to the catalog (existing POST /test-templates). Operators (red_tech/blue_tech) get the same button, but their proposal now lands in a new template_suggestions review queue instead — a lead on their team can approve it as-is, edit fields before approving, or discard it. Modeled on the existing ProcedureSuggestion review workflow.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""End-to-end coverage for the operator template-proposal review workflow.
|
||||
|
||||
Leads create test templates directly via POST /test-templates. An operator
|
||||
(red_tech/blue_tech) gets the same "propose a template" action, but their
|
||||
submission lands in the review queue instead of the live catalog — a lead
|
||||
on their team approves (optionally editing fields) or discards it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
base = {
|
||||
"mitre_technique_id": "T1059.300",
|
||||
"name": "Operator-proposed template",
|
||||
"attack_procedure": "Run a discovery command.",
|
||||
"expected_detection": "Check process creation logs.",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def technique(api, auth_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.300", "name": "Command Line Proposals"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def test_red_tech_can_propose_a_template(api, technique, red_tech_headers):
|
||||
resp = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["team"] == "red"
|
||||
assert body["name"] == "Operator-proposed template"
|
||||
|
||||
|
||||
def test_blue_tech_can_propose_a_template(api, technique, blue_tech_headers):
|
||||
resp = api("post", "/api/v1/template-suggestions", blue_tech_headers, json=_payload())
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["team"] == "blue"
|
||||
|
||||
|
||||
def test_lead_cannot_propose_a_template_via_suggestion_endpoint(api, technique, red_lead_headers):
|
||||
"""Leads use the direct POST /test-templates endpoint instead."""
|
||||
resp = api("post", "/api/v1/template-suggestions", red_lead_headers, json=_payload())
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_proposed_template_does_not_appear_in_catalog(api, technique, red_tech_headers, red_lead_headers):
|
||||
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
|
||||
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
|
||||
assert catalog.json() == []
|
||||
|
||||
|
||||
def test_red_lead_sees_pending_red_suggestion(api, technique, red_tech_headers, red_lead_headers):
|
||||
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert len(listed.json()) == 1
|
||||
assert listed.json()[0]["status"] == "pending"
|
||||
|
||||
|
||||
def test_blue_lead_does_not_see_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
|
||||
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", blue_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_blue_lead_cannot_approve_a_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", blue_lead_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_lead_approves_suggestion_as_is_creates_template(api, technique, red_tech_headers, red_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "approved"
|
||||
assert body["created_template_id"] is not None
|
||||
|
||||
template = api("get", f"/api/v1/test-templates/{body['created_template_id']}", red_lead_headers)
|
||||
assert template.status_code == 200
|
||||
assert template.json()["name"] == "Operator-proposed template"
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_lead_approves_suggestion_with_edits(api, technique, red_tech_headers, red_lead_headers):
|
||||
"""The lead can add/adjust information before it goes live — the created
|
||||
template reflects the lead's edits, not just the operator's original text."""
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
|
||||
json={"attack_procedure": "Run a discovery command, then pivot via WMI.", "severity": "high"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
template_id = resp.json()["created_template_id"]
|
||||
|
||||
template = api("get", f"/api/v1/test-templates/{template_id}", red_lead_headers)
|
||||
assert template.json()["attack_procedure"] == "Run a discovery command, then pivot via WMI."
|
||||
assert template.json()["severity"] == "high"
|
||||
# Untouched fields still come from the operator's original submission.
|
||||
assert template.json()["expected_detection"] == "Check process creation logs."
|
||||
|
||||
|
||||
def test_lead_discards_suggestion_without_creating_template(api, technique, red_tech_headers, red_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "rejected"
|
||||
assert resp.json()["created_template_id"] is None
|
||||
|
||||
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
|
||||
assert catalog.json() == []
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_cannot_review_an_already_reviewed_suggestion_twice(api, technique, red_tech_headers, red_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user