From 4f5ffcf3f9157826ea9044bfb50a8d1283a4d540 Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 3 Jul 2026 12:02:28 +0200 Subject: [PATCH] feat(campaigns): submit/approve/reject router endpoints --- backend/app/routers/campaigns.py | 114 +++++++++++++++++ backend/app/services/campaign_crud_service.py | 3 + .../tests/test_campaign_approval_router.py | 118 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 backend/tests/test_campaign_approval_router.py diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index a3afbff..d798946 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -91,6 +91,16 @@ from app.services.campaign_crud_service import ( activate_campaign as crud_activate, ) +from app.services.campaign_crud_service import ( + submit_campaign_for_approval as crud_submit, +) +from app.services.campaign_crud_service import ( + approve_campaign as crud_approve, +) +from app.services.campaign_crud_service import ( + reject_campaign as crud_reject, +) + # Import log_action from app.services.audit_service from app.services.audit_service import log_action @@ -172,6 +182,18 @@ class SchedulePayload(BaseModel): next_run_at: Optional[str] = None +class ApprovePayload(BaseModel): + """Payload for a manager approving a pending campaign.""" + + start_date: str # ISO date/datetime — required, campaign won't activate without it + + +class RejectPayload(BaseModel): + """Payload for a manager rejecting a pending campaign.""" + + reason: str + + # --------------------------------------------------------------------------- # GET /campaigns — List campaigns with filters # --------------------------------------------------------------------------- @@ -385,6 +407,98 @@ def update_campaign( return result +# --------------------------------------------------------------------------- +# POST /campaigns/{id}/submit — Submit draft for manager approval +# --------------------------------------------------------------------------- + +@router.post("/{campaign_id}/submit") +def submit_campaign( + campaign_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), +) -> dict: + """Submit a draft campaign into the manager's approval queue.""" + with UnitOfWork(db) as uow: + campaign = crud_submit( + db, campaign_id, + submitter_id=current_user.id, + submitter_role=current_user.role, + ) + log_action( + db, + user_id=current_user.id, + action="submit_campaign_for_approval", + entity_type="campaign", + entity_id=campaign.id, + details={"name": campaign.name}, + ) + uow.commit() + db.refresh(campaign) + return serialize_campaign(db, campaign) + + +# --------------------------------------------------------------------------- +# POST /campaigns/{id}/approve — Manager approves a pending campaign +# --------------------------------------------------------------------------- + +@router.post("/{campaign_id}/approve") +def approve_campaign_endpoint( + campaign_id: str, + payload: ApprovePayload, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("manager")), +) -> dict: + """Manager approves a pending campaign, fixing its start date and activating it.""" + with UnitOfWork(db) as uow: + campaign = crud_approve( + db, campaign_id, + approver_id=current_user.id, + start_date=payload.start_date, + ) + log_action( + db, + user_id=current_user.id, + action="approve_campaign", + entity_type="campaign", + entity_id=campaign.id, + details={"start_date": payload.start_date}, + ) + uow.commit() + db.refresh(campaign) + return serialize_campaign(db, campaign) + + +# --------------------------------------------------------------------------- +# POST /campaigns/{id}/reject — Manager rejects a pending campaign +# --------------------------------------------------------------------------- + +@router.post("/{campaign_id}/reject") +def reject_campaign_endpoint( + campaign_id: str, + payload: RejectPayload, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("manager")), +) -> dict: + """Manager rejects a pending campaign, returning it to draft with a reason.""" + with UnitOfWork(db) as uow: + campaign = crud_reject( + db, campaign_id, + rejecter_id=current_user.id, + reason=payload.reason, + ) + log_action( + db, + user_id=current_user.id, + action="reject_campaign", + entity_type="campaign", + entity_id=campaign.id, + details={"reason": payload.reason}, + ) + uow.commit() + db.refresh(campaign) + return serialize_campaign(db, campaign) + + # --------------------------------------------------------------------------- # DELETE /campaigns/{id} — Delete campaign # --------------------------------------------------------------------------- diff --git a/backend/app/services/campaign_crud_service.py b/backend/app/services/campaign_crud_service.py index cc18313..a48a161 100644 --- a/backend/app/services/campaign_crud_service.py +++ b/backend/app/services/campaign_crud_service.py @@ -128,6 +128,9 @@ def serialize_campaign(db: Session, campaign: Campaign) -> dict: "scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None, # Literal argument value "completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None, + "approved_by": str(campaign.approved_by) if campaign.approved_by else None, + "approved_at": campaign.approved_at.isoformat() if campaign.approved_at else None, + "rejection_reason": campaign.rejection_reason, # Literal argument value "target_platform": campaign.target_platform, # Literal argument value diff --git a/backend/tests/test_campaign_approval_router.py b/backend/tests/test_campaign_approval_router.py new file mode 100644 index 0000000..03e9b70 --- /dev/null +++ b/backend/tests/test_campaign_approval_router.py @@ -0,0 +1,118 @@ +"""Router-level tests for the campaign manager-approval workflow.""" + +from app.models.campaign import Campaign, CampaignTest +from app.models.technique import Technique +from app.models.test import Test +from app.models.enums import TestState + + +def _post(client, url, headers, **kwargs): + """POST while forcing auth via the Authorization header. + + The login endpoint also sets an HttpOnly ``aegis_token`` cookie, and the + shared ``TestClient`` cookie jar persists across requests within a test. + ``get_current_user`` prefers the cookie over the ``Authorization`` + header, so once a second role's headers fixture logs in (setting its own + cookie), a request made with an *earlier* role's headers would silently + authenticate as the later role unless the leftover cookie is cleared. + """ + client.cookies.clear() + return client.post(url, headers=headers, **kwargs) + + +def _make_draft_campaign(db, owner_id): + tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"]) + db.add(tech) + db.flush() + campaign = Campaign(name="Router Test Campaign", type="custom", status="draft", created_by=owner_id) + db.add(campaign) + db.flush() + test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=owner_id) + db.add(test) + db.flush() + db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0)) + db.commit() + db.refresh(campaign) + return campaign + + +def test_lead_can_submit_own_campaign(client, db, red_lead_user, red_lead_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + resp = _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + assert resp.status_code == 200 + assert resp.json()["status"] == "pending_approval" + + +def test_red_tech_cannot_submit(client, db, red_lead_user, red_tech_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + resp = _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_tech_headers) + assert resp.status_code == 403 + + +def test_manager_can_approve_and_sets_start_date(client, db, red_lead_user, red_lead_headers, manager_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + + resp = _post( + client, + f"/api/v1/campaigns/{campaign.id}/approve", + manager_headers, + json={"start_date": "2026-09-01T00:00:00"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "active" + assert body["start_date"] is not None + + +def test_lead_cannot_approve(client, db, red_lead_user, red_lead_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + + resp = _post( + client, + f"/api/v1/campaigns/{campaign.id}/approve", + red_lead_headers, + json={"start_date": "2026-09-01T00:00:00"}, + ) + assert resp.status_code == 403 + + +def test_admin_can_approve_as_manager_backup(client, db, red_lead_user, red_lead_headers, auth_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + + resp = _post( + client, + f"/api/v1/campaigns/{campaign.id}/approve", + auth_headers, + json={"start_date": "2026-09-01T00:00:00"}, + ) + assert resp.status_code == 200 + + +def test_manager_can_reject_with_reason(client, db, red_lead_user, red_lead_headers, manager_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + + resp = _post( + client, + f"/api/v1/campaigns/{campaign.id}/reject", + manager_headers, + json={"reason": "Needs more detail"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "draft" + + +def test_manager_reject_without_reason_rejected_by_validation(client, db, red_lead_user, red_lead_headers, manager_headers): + campaign = _make_draft_campaign(db, red_lead_user.id) + _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + + resp = _post( + client, + f"/api/v1/campaigns/{campaign.id}/reject", + manager_headers, + json={"reason": ""}, + ) + assert resp.status_code == 400