3051 lines
114 KiB
Markdown
3051 lines
114 KiB
Markdown
# Campaign Manager Approval Workflow Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a `manager` role that approves campaigns leads build, fixes their launch dates, and reviews justified requests to add/remove tests from an already-active campaign — with every action logged to a queryable timeline.
|
||
|
||
**Architecture:** Campaign gains a `pending_approval` status between `draft` and `active`. Leads submit drafts into that queue; a manager (or admin) approves (setting `start_date`) or rejects (with a mandatory reason, returning it to `draft`). Once `active`, leads can no longer edit the test list directly — they file a `CampaignModificationRequest` (with mandatory justification) that a manager must approve before the underlying test is actually added/removed. All of it reuses the existing generic `AuditLog` (new action names) and exposes a `GET /campaigns/{id}/timeline` endpoint, mirroring the one that already exists for tests.
|
||
|
||
**Tech Stack:** FastAPI + SQLAlchemy + Alembic (backend), React 19 + TypeScript + TanStack Query (frontend), pytest + in-memory SQLite (tests).
|
||
|
||
---
|
||
|
||
## Context for the implementing engineer
|
||
|
||
- Read [backend/app/services/campaign_crud_service.py](backend/app/services/campaign_crud_service.py) fully before starting — nearly every backend task edits this file.
|
||
- Read [backend/app/routers/campaigns.py](backend/app/routers/campaigns.py) fully before starting Task 9 onward.
|
||
- `require_any_role(*roles)` (in `app/dependencies/auth.py`) already lets `admin` through unconditionally — so `require_any_role("manager")` alone is enough to satisfy "admin can also approve." You never need to write `require_any_role("manager", "admin")`.
|
||
- Domain errors (`EntityNotFoundError`, `BusinessRuleViolation`, `PermissionViolation`) are raised in the service layer and converted to HTTP 404/400/403 automatically by `app/middleware/error_handler.py` — routers never raise `HTTPException` for these cases, they just let the exception propagate.
|
||
- Service functions never call `db.commit()` — only `db.flush()`. The router wraps calls in `with UnitOfWork(db) as uow: ... uow.commit()`. Follow this exactly.
|
||
- Backend tests use the fixtures in `backend/tests/conftest.py` (`client`, `db`, `admin_user`/`admin_token`, `red_lead_user`/`red_lead_headers`, etc.) against a real (in-memory SQLite) FastAPI app — no mocking. Run pytest from the `backend/` directory.
|
||
- Frontend has no test runner configured. Frontend tasks are verified with `npm run build` (TypeScript check) inside `frontend/` and manual verification in the browser via the dev server.
|
||
- This plan deliberately leaves `app/services/campaign_scheduler_service.py` (recurring campaign auto-clone, which sets `status="active"` directly) untouched — a recurring child campaign is a re-run of an already-approved template, not a new campaign a lead is proposing, so it doesn't need to re-enter the approval queue.
|
||
|
||
---
|
||
|
||
## Task 1: Domain entity — add `pending_approval` state and transition methods
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/domain/entities/campaign.py`
|
||
- Test: `backend/tests/test_campaign_entity.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `backend/tests/test_campaign_entity.py`:
|
||
|
||
```python
|
||
# ── 9. Submit for approval ────────────────────────────────────────────
|
||
|
||
|
||
def test_submit_for_approval_from_draft_with_tests_success():
|
||
e = _entity("draft", test_count=1)
|
||
e.submit_for_approval()
|
||
assert e.status == CampaignStatus.pending_approval
|
||
|
||
|
||
def test_submit_for_approval_from_draft_with_zero_tests_raises():
|
||
e = _entity("draft", test_count=0)
|
||
with pytest.raises(BusinessRuleViolation, match="at least one test"):
|
||
e.submit_for_approval()
|
||
assert e.status == CampaignStatus.draft
|
||
|
||
|
||
def test_submit_for_approval_from_active_raises():
|
||
e = _entity("active", test_count=1)
|
||
with pytest.raises(InvalidStateTransition):
|
||
e.submit_for_approval()
|
||
|
||
|
||
# ── 10. Approve ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_approve_from_pending_approval_success():
|
||
e = _entity("pending_approval", test_count=1)
|
||
e.approve()
|
||
assert e.status == CampaignStatus.active
|
||
|
||
|
||
def test_approve_from_draft_raises():
|
||
e = _entity("draft", test_count=1)
|
||
with pytest.raises(InvalidStateTransition):
|
||
e.approve()
|
||
|
||
|
||
# ── 11. Reject ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_reject_from_pending_approval_returns_to_draft():
|
||
e = _entity("pending_approval", test_count=1)
|
||
e.reject()
|
||
assert e.status == CampaignStatus.draft
|
||
|
||
|
||
def test_reject_from_draft_raises():
|
||
e = _entity("draft", test_count=1)
|
||
with pytest.raises(InvalidStateTransition):
|
||
e.reject()
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run (from `backend/`): `pytest tests/test_campaign_entity.py -v -k "submit_for_approval or test_approve or test_reject"`
|
||
Expected: FAIL — `AttributeError: 'CampaignEntity' object has no attribute 'submit_for_approval'` (and `CampaignStatus.pending_approval` doesn't exist yet).
|
||
|
||
- [ ] **Step 3: Implement the state and methods**
|
||
|
||
In `backend/app/domain/entities/campaign.py`, update `CampaignStatus`:
|
||
|
||
```python
|
||
class CampaignStatus(str, enum.Enum):
|
||
"""Lifecycle states for a campaign."""
|
||
|
||
draft = "draft"
|
||
pending_approval = "pending_approval"
|
||
active = "active"
|
||
completed = "completed"
|
||
archived = "archived"
|
||
```
|
||
|
||
Update `VALID_TRANSITIONS`:
|
||
|
||
```python
|
||
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
|
||
CampaignStatus.draft: [CampaignStatus.pending_approval],
|
||
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
|
||
CampaignStatus.active: [CampaignStatus.completed],
|
||
CampaignStatus.completed: [CampaignStatus.archived],
|
||
CampaignStatus.archived: [],
|
||
}
|
||
```
|
||
|
||
Replace the `activate` method and add `submit_for_approval`, `approve`, `reject` right after it:
|
||
|
||
```python
|
||
def submit_for_approval(self) -> None:
|
||
"""Transition the campaign from ``draft`` to ``pending_approval``.
|
||
|
||
Returns:
|
||
None
|
||
"""
|
||
if not self.can_transition_to(CampaignStatus.pending_approval):
|
||
raise InvalidStateTransition(
|
||
self.status.value, CampaignStatus.pending_approval.value,
|
||
[s.value for s in VALID_TRANSITIONS[self.status]],
|
||
)
|
||
if self.test_count == 0:
|
||
raise BusinessRuleViolation(
|
||
"Campaign must have at least one test to submit for approval"
|
||
)
|
||
self.status = CampaignStatus.pending_approval
|
||
|
||
def approve(self) -> None:
|
||
"""Transition the campaign from ``pending_approval`` to ``active``.
|
||
|
||
Returns:
|
||
None
|
||
"""
|
||
if not self.can_transition_to(CampaignStatus.active):
|
||
raise InvalidStateTransition(
|
||
self.status.value, CampaignStatus.active.value,
|
||
[s.value for s in VALID_TRANSITIONS[self.status]],
|
||
)
|
||
self.status = CampaignStatus.active
|
||
|
||
def reject(self) -> None:
|
||
"""Transition the campaign from ``pending_approval`` back to ``draft``.
|
||
|
||
Returns:
|
||
None
|
||
"""
|
||
if not self.can_transition_to(CampaignStatus.draft):
|
||
raise InvalidStateTransition(
|
||
self.status.value, CampaignStatus.draft.value,
|
||
[s.value for s in VALID_TRANSITIONS[self.status]],
|
||
)
|
||
self.status = CampaignStatus.draft
|
||
```
|
||
|
||
Leave the existing `activate` method as-is (it's superseded by `submit_for_approval`/`approve` but removing it would break `test_activate_from_draft_with_tests_success` etc. — out of scope to touch those).
|
||
|
||
Update `ensure_modifiable` so campaigns can still be read while pending approval isn't treated as an error state for callers that only check draft/active — leave this method unchanged; it already raises for anything other than draft/active, which is correct (pending_approval campaigns are frozen).
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `pytest tests/test_campaign_entity.py -v`
|
||
Expected: PASS — all tests including the pre-existing ones (the old `activate`/`ensure_modifiable` tests must still pass unchanged).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/domain/entities/campaign.py backend/tests/test_campaign_entity.py
|
||
git commit -m "feat(campaigns): add pending_approval state to campaign domain entity"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: ORM models — Campaign approval fields + CampaignModificationRequest
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/models/campaign.py`
|
||
- Modify: `backend/app/models/__init__.py`
|
||
|
||
- [ ] **Step 1: Add approval fields to `Campaign`**
|
||
|
||
In `backend/app/models/campaign.py`, add these columns right after `start_date`/`scheduled_at` (after line 77):
|
||
|
||
```python
|
||
approved_by = Column(
|
||
UUID(as_uuid=True),
|
||
ForeignKey("users.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
approved_at = Column(DateTime, nullable=True)
|
||
rejection_reason = Column(Text, nullable=True)
|
||
```
|
||
|
||
- [ ] **Step 2: Add the `CampaignModificationRequest` model**
|
||
|
||
Append to the bottom of `backend/app/models/campaign.py`, after the `CampaignTest` class:
|
||
|
||
```python
|
||
class CampaignModificationRequest(Base):
|
||
"""A lead's request to add or remove a test from an already-active campaign.
|
||
|
||
The underlying ``CampaignTest`` row is only created/deleted once a
|
||
manager approves the request — see ``approve_modification_request``
|
||
in ``campaign_crud_service.py``.
|
||
"""
|
||
__tablename__ = "campaign_modification_requests"
|
||
|
||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
campaign_id = Column(
|
||
UUID(as_uuid=True),
|
||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
)
|
||
requested_by = Column(
|
||
UUID(as_uuid=True),
|
||
ForeignKey("users.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
action = Column(String, nullable=False) # add_test, remove_test
|
||
test_id = Column(
|
||
UUID(as_uuid=True),
|
||
ForeignKey("tests.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
)
|
||
order_index = Column(Integer, nullable=True)
|
||
phase = Column(String, nullable=True)
|
||
justification = Column(Text, nullable=False)
|
||
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
|
||
reviewed_by = Column(
|
||
UUID(as_uuid=True),
|
||
ForeignKey("users.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
reviewed_at = Column(DateTime, nullable=True)
|
||
review_notes = Column(Text, nullable=True)
|
||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||
|
||
campaign = relationship("Campaign")
|
||
test = relationship("Test")
|
||
requester = relationship("User", foreign_keys=[requested_by])
|
||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||
|
||
__table_args__ = (
|
||
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
|
||
Index("ix_campaign_mod_requests_status", "status"),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Register the new model**
|
||
|
||
In `backend/app/models/__init__.py`, change line 11:
|
||
|
||
```python
|
||
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest
|
||
```
|
||
|
||
And in `__all__` (line 62), change:
|
||
|
||
```python
|
||
"Campaign", "CampaignTest", "CampaignModificationRequest",
|
||
```
|
||
|
||
- [ ] **Step 4: Verify the app still imports cleanly**
|
||
|
||
Run (from `backend/`): `python -c "import app.models; print('ok')"`
|
||
Expected: prints `ok` with no traceback.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/models/campaign.py backend/app/models/__init__.py
|
||
git commit -m "feat(campaigns): add approval fields and CampaignModificationRequest model"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Migration — campaign approval columns
|
||
|
||
**Files:**
|
||
- Create: `backend/alembic/versions/b054_add_campaign_approval_fields.py`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
```python
|
||
"""Add approved_by, approved_at, rejection_reason to campaigns.
|
||
|
||
Revision ID: b054
|
||
Revises: b053
|
||
Create Date: 2026-07-02
|
||
"""
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
revision = "b054"
|
||
down_revision = "b053"
|
||
branch_labels = None
|
||
depends_on = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.add_column(
|
||
"campaigns",
|
||
sa.Column("approved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||
)
|
||
op.add_column("campaigns", sa.Column("approved_at", sa.DateTime(), nullable=True))
|
||
op.add_column("campaigns", sa.Column("rejection_reason", sa.Text(), nullable=True))
|
||
op.create_foreign_key(
|
||
"fk_campaigns_approved_by_users",
|
||
"campaigns", "users",
|
||
["approved_by"], ["id"],
|
||
ondelete="SET NULL",
|
||
)
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_constraint("fk_campaigns_approved_by_users", "campaigns", type_="foreignkey")
|
||
op.drop_column("campaigns", "rejection_reason")
|
||
op.drop_column("campaigns", "approved_at")
|
||
op.drop_column("campaigns", "approved_by")
|
||
```
|
||
|
||
- [ ] **Step 2: Verify migration chain is valid**
|
||
|
||
Run (from `backend/`): `alembic heads`
|
||
Expected: prints `b054 (head)` — confirming no branch conflicts.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/alembic/versions/b054_add_campaign_approval_fields.py
|
||
git commit -m "feat(campaigns): migration for campaign approval fields"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Migration — campaign_modification_requests table
|
||
|
||
**Files:**
|
||
- Create: `backend/alembic/versions/b055_add_campaign_modification_requests.py`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
```python
|
||
"""Add campaign_modification_requests table.
|
||
|
||
Revision ID: b055
|
||
Revises: b054
|
||
Create Date: 2026-07-02
|
||
"""
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
revision = "b055"
|
||
down_revision = "b054"
|
||
branch_labels = None
|
||
depends_on = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.create_table(
|
||
"campaign_modification_requests",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
sa.Column(
|
||
"campaign_id", postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False,
|
||
),
|
||
sa.Column(
|
||
"requested_by", postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||
),
|
||
sa.Column("action", sa.String(), nullable=False),
|
||
sa.Column(
|
||
"test_id", postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("tests.id", ondelete="CASCADE"), nullable=False,
|
||
),
|
||
sa.Column("order_index", sa.Integer(), nullable=True),
|
||
sa.Column("phase", sa.String(), nullable=True),
|
||
sa.Column("justification", sa.Text(), nullable=False),
|
||
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
|
||
sa.Column(
|
||
"reviewed_by", postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||
),
|
||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||
sa.Column("review_notes", sa.Text(), nullable=True),
|
||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
)
|
||
op.create_index(
|
||
"ix_campaign_mod_requests_campaign",
|
||
"campaign_modification_requests", ["campaign_id"],
|
||
)
|
||
op.create_index(
|
||
"ix_campaign_mod_requests_status",
|
||
"campaign_modification_requests", ["status"],
|
||
)
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_index("ix_campaign_mod_requests_status", table_name="campaign_modification_requests")
|
||
op.drop_index("ix_campaign_mod_requests_campaign", table_name="campaign_modification_requests")
|
||
op.drop_table("campaign_modification_requests")
|
||
```
|
||
|
||
- [ ] **Step 2: Verify migration chain is valid**
|
||
|
||
Run (from `backend/`): `alembic heads`
|
||
Expected: prints `b055 (head)`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/alembic/versions/b055_add_campaign_modification_requests.py
|
||
git commit -m "feat(campaigns): migration for campaign_modification_requests table"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Test fixtures — manager role
|
||
|
||
**Files:**
|
||
- Modify: `backend/tests/conftest.py`
|
||
|
||
- [ ] **Step 1: Add the `manager_user` fixture**
|
||
|
||
Insert right after the `blue_lead_user` fixture (after line 217) in `backend/tests/conftest.py`:
|
||
|
||
```python
|
||
@pytest.fixture(scope="function")
|
||
def manager_user(db):
|
||
"""Create a manager user for testing."""
|
||
user = User(
|
||
username="manager",
|
||
email="manager@test.com",
|
||
hashed_password=hash_password("manager123"),
|
||
role="manager",
|
||
is_active=True,
|
||
must_change_password=False,
|
||
)
|
||
db.add(user)
|
||
db.commit()
|
||
db.refresh(user)
|
||
return user
|
||
```
|
||
|
||
- [ ] **Step 2: Add the `manager_token` and `manager_headers` fixtures**
|
||
|
||
Insert right after the `blue_lead_headers` fixture (after line 297):
|
||
|
||
```python
|
||
@pytest.fixture(scope="function")
|
||
def manager_token(client, manager_user):
|
||
"""Get an auth token for the manager user."""
|
||
response = client.post(
|
||
"/api/v1/auth/login",
|
||
data={"username": "manager", "password": "manager123"},
|
||
)
|
||
return response.json()["access_token"]
|
||
|
||
|
||
@pytest.fixture(scope="function")
|
||
def manager_headers(manager_token):
|
||
"""Return authorization headers for manager user."""
|
||
return {"Authorization": f"Bearer {manager_token}"}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify the fixtures load without error**
|
||
|
||
Run (from `backend/`): `pytest tests/test_user_api_validation.py -v`
|
||
Expected: PASS (unrelated test file — this just confirms conftest.py still parses and collects cleanly after the edit).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/tests/conftest.py
|
||
git commit -m "test(campaigns): add manager role fixtures"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Service layer — submit / approve / reject campaign
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/services/campaign_crud_service.py`
|
||
- Test: `backend/tests/test_campaign_approval.py` (new file)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `backend/tests/test_campaign_approval.py`:
|
||
|
||
```python
|
||
"""Tests for the campaign manager-approval workflow."""
|
||
|
||
import pytest
|
||
|
||
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
|
||
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError, PermissionViolation
|
||
from app.services.campaign_crud_service import (
|
||
create_campaign,
|
||
submit_campaign_for_approval,
|
||
approve_campaign,
|
||
reject_campaign,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def draft_campaign_with_test(db, admin_user):
|
||
"""A draft campaign with exactly one test, created by admin_user."""
|
||
tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
|
||
db.add(tech)
|
||
db.flush()
|
||
|
||
campaign = Campaign(name="Approval Test Campaign", type="custom", status="draft", created_by=admin_user.id)
|
||
db.add(campaign)
|
||
db.flush()
|
||
|
||
test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=admin_user.id)
|
||
db.add(test)
|
||
db.flush()
|
||
|
||
ct = CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0)
|
||
db.add(ct)
|
||
db.commit()
|
||
db.refresh(campaign)
|
||
return campaign
|
||
|
||
|
||
class TestSubmitCampaignForApproval:
|
||
def test_submit_moves_draft_to_pending_approval(self, db, draft_campaign_with_test, admin_user):
|
||
campaign = submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id),
|
||
submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
assert campaign.status == "pending_approval"
|
||
|
||
def test_submit_without_tests_raises(self, db, admin_user):
|
||
campaign = Campaign(name="Empty", type="custom", status="draft", created_by=admin_user.id)
|
||
db.add(campaign)
|
||
db.commit()
|
||
with pytest.raises(BusinessRuleViolation, match="at least one test"):
|
||
submit_campaign_for_approval(
|
||
db, str(campaign.id), submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
|
||
def test_submit_by_non_creator_non_admin_raises(self, db, draft_campaign_with_test, red_lead_user):
|
||
with pytest.raises(PermissionViolation):
|
||
submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id),
|
||
submitter_id=red_lead_user.id, submitter_role="red_lead",
|
||
)
|
||
|
||
def test_submit_non_draft_raises(self, db, draft_campaign_with_test, admin_user):
|
||
draft_campaign_with_test.status = "active"
|
||
db.commit()
|
||
with pytest.raises(BusinessRuleViolation, match="Only draft campaigns"):
|
||
submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id),
|
||
submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
|
||
|
||
class TestApproveCampaign:
|
||
def test_approve_sets_start_date_and_activates(self, db, draft_campaign_with_test, admin_user, manager_user=None):
|
||
submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
db.commit()
|
||
campaign = approve_campaign(
|
||
db, str(draft_campaign_with_test.id),
|
||
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
|
||
)
|
||
assert campaign.status == "active"
|
||
assert campaign.start_date is not None
|
||
assert campaign.approved_by == admin_user.id
|
||
assert campaign.approved_at is not None
|
||
|
||
def test_approve_without_start_date_raises(self, db, draft_campaign_with_test, admin_user):
|
||
submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
db.commit()
|
||
with pytest.raises(BusinessRuleViolation, match="start_date is required"):
|
||
approve_campaign(db, str(draft_campaign_with_test.id), approver_id=admin_user.id, start_date="")
|
||
|
||
def test_approve_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
|
||
with pytest.raises(BusinessRuleViolation, match="pending approval"):
|
||
approve_campaign(
|
||
db, str(draft_campaign_with_test.id),
|
||
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
|
||
)
|
||
|
||
|
||
class TestRejectCampaign:
|
||
def test_reject_returns_to_draft_with_reason(self, db, draft_campaign_with_test, admin_user):
|
||
submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
db.commit()
|
||
campaign = reject_campaign(
|
||
db, str(draft_campaign_with_test.id),
|
||
rejecter_id=admin_user.id, reason="Missing scope details",
|
||
)
|
||
assert campaign.status == "draft"
|
||
assert campaign.rejection_reason == "Missing scope details"
|
||
|
||
def test_reject_without_reason_raises(self, db, draft_campaign_with_test, admin_user):
|
||
submit_campaign_for_approval(
|
||
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||
)
|
||
db.commit()
|
||
with pytest.raises(BusinessRuleViolation, match="reason is required"):
|
||
reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="")
|
||
|
||
def test_reject_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
|
||
with pytest.raises(BusinessRuleViolation, match="pending approval"):
|
||
reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="No")
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run (from `backend/`): `pytest tests/test_campaign_approval.py -v`
|
||
Expected: FAIL — `ImportError: cannot import name 'submit_campaign_for_approval' from 'app.services.campaign_crud_service'`.
|
||
|
||
- [ ] **Step 3: Implement the service functions**
|
||
|
||
In `backend/app/services/campaign_crud_service.py`, add right after `create_campaign` (after line 306, before `get_campaign_detail`):
|
||
|
||
```python
|
||
def submit_campaign_for_approval(
|
||
db: Session,
|
||
campaign_id: str,
|
||
*,
|
||
submitter_id: uuid.UUID,
|
||
submitter_role: str,
|
||
) -> Campaign:
|
||
"""Submit a draft campaign into the manager's approval queue.
|
||
|
||
Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation.
|
||
Does not commit; caller commits.
|
||
"""
|
||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||
if not campaign:
|
||
raise EntityNotFoundError("Campaign", campaign_id)
|
||
|
||
if str(campaign.created_by) != str(submitter_id) and submitter_role != "admin":
|
||
raise PermissionViolation("Only the creator or admin can submit this campaign for approval")
|
||
|
||
if campaign.status != "draft":
|
||
raise BusinessRuleViolation("Only draft campaigns can be submitted for approval")
|
||
|
||
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).count()
|
||
if test_count == 0:
|
||
raise BusinessRuleViolation("Campaign must have at least one test to submit for approval")
|
||
|
||
campaign.status = "pending_approval"
|
||
db.flush()
|
||
return campaign
|
||
|
||
|
||
def approve_campaign(
|
||
db: Session,
|
||
campaign_id: str,
|
||
*,
|
||
approver_id: uuid.UUID,
|
||
start_date: str,
|
||
) -> Campaign:
|
||
"""Approve a pending campaign: fix its start date and activate it.
|
||
|
||
Raises EntityNotFoundError, BusinessRuleViolation.
|
||
Does not commit; caller commits.
|
||
"""
|
||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||
if not campaign:
|
||
raise EntityNotFoundError("Campaign", campaign_id)
|
||
|
||
if campaign.status != "pending_approval":
|
||
raise BusinessRuleViolation("Only campaigns pending approval can be approved")
|
||
|
||
if not start_date:
|
||
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
||
|
||
campaign.start_date = datetime.fromisoformat(start_date)
|
||
campaign.status = "active"
|
||
campaign.approved_by = approver_id
|
||
campaign.approved_at = datetime.utcnow()
|
||
campaign.rejection_reason = None
|
||
db.flush()
|
||
return campaign
|
||
|
||
|
||
def reject_campaign(
|
||
db: Session,
|
||
campaign_id: str,
|
||
*,
|
||
rejecter_id: uuid.UUID,
|
||
reason: str,
|
||
) -> Campaign:
|
||
"""Reject a pending campaign, returning it to draft with a mandatory reason.
|
||
|
||
Raises EntityNotFoundError, BusinessRuleViolation.
|
||
Does not commit; caller commits.
|
||
"""
|
||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||
if not campaign:
|
||
raise EntityNotFoundError("Campaign", campaign_id)
|
||
|
||
if campaign.status != "pending_approval":
|
||
raise BusinessRuleViolation("Only campaigns pending approval can be rejected")
|
||
|
||
if not reason or not reason.strip():
|
||
raise BusinessRuleViolation("A rejection reason is required")
|
||
|
||
campaign.status = "draft"
|
||
campaign.rejection_reason = reason.strip()
|
||
campaign.approved_by = None
|
||
campaign.approved_at = None
|
||
db.flush()
|
||
return campaign
|
||
```
|
||
|
||
Note: `rejecter_id` is accepted for symmetry with `approve_campaign`'s `approver_id` but isn't stored (there's no `rejected_by` column — `rejection_reason` plus the audit log entry the router writes are the record of who rejected). Leave the parameter in the signature since the router will always pass it and a future column could use it.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `pytest tests/test_campaign_approval.py -v`
|
||
Expected: PASS — all 10 tests green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval.py
|
||
git commit -m "feat(campaigns): submit/approve/reject service functions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Service layer — gate test add/remove to draft, add modification requests
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/services/campaign_crud_service.py`
|
||
- Test: `backend/tests/test_campaign_approval.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `backend/tests/test_campaign_approval.py`:
|
||
|
||
```python
|
||
from app.services.campaign_crud_service import (
|
||
add_test_to_campaign,
|
||
remove_test_from_campaign,
|
||
create_modification_request,
|
||
approve_modification_request,
|
||
reject_modification_request,
|
||
list_modification_requests,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def active_campaign_with_test(db, draft_campaign_with_test, admin_user):
|
||
"""An active campaign (bypasses the approval flow directly for test setup)."""
|
||
draft_campaign_with_test.status = "active"
|
||
db.commit()
|
||
db.refresh(draft_campaign_with_test)
|
||
return draft_campaign_with_test
|
||
|
||
|
||
class TestDirectTestEditingGate:
|
||
def test_add_test_blocked_on_active_campaign(self, db, active_campaign_with_test, admin_user):
|
||
tech = Technique(mitre_id="T1078", name="Valid Accounts", tactic="persistence", platforms=["windows"])
|
||
db.add(tech)
|
||
db.flush()
|
||
new_test = Test(technique_id=tech.id, name="New test", state=TestState.draft, created_by=admin_user.id)
|
||
db.add(new_test)
|
||
db.commit()
|
||
|
||
with pytest.raises(BusinessRuleViolation, match="draft"):
|
||
add_test_to_campaign(db, str(active_campaign_with_test.id), test_id=str(new_test.id))
|
||
|
||
def test_remove_test_blocked_on_active_campaign(self, db, active_campaign_with_test):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||
with pytest.raises(BusinessRuleViolation, match="draft"):
|
||
remove_test_from_campaign(db, str(active_campaign_with_test.id), str(ct.id))
|
||
|
||
|
||
class TestModificationRequests:
|
||
def test_create_add_test_request(self, db, active_campaign_with_test, admin_user):
|
||
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||
db.add(tech)
|
||
db.flush()
|
||
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
|
||
db.add(new_test)
|
||
db.commit()
|
||
|
||
request = create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="add_test",
|
||
test_id=str(new_test.id), justification="Coverage gap found in review",
|
||
)
|
||
assert request.status == "pending"
|
||
|
||
# Underlying campaign is untouched until approval
|
||
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||
assert len(cts) == 1
|
||
|
||
def test_create_request_without_justification_raises(self, db, active_campaign_with_test, admin_user):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||
with pytest.raises(BusinessRuleViolation, match="justification is required"):
|
||
create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="remove_test",
|
||
test_id=str(ct.test_id), justification="",
|
||
)
|
||
|
||
def test_create_request_on_non_active_campaign_raises(self, db, draft_campaign_with_test, admin_user):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == draft_campaign_with_test.id).first()
|
||
with pytest.raises(BusinessRuleViolation, match="active campaigns"):
|
||
create_modification_request(
|
||
db, str(draft_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="remove_test",
|
||
test_id=str(ct.test_id), justification="Not needed",
|
||
)
|
||
|
||
def test_approve_add_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
|
||
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||
db.add(tech)
|
||
db.flush()
|
||
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
|
||
db.add(new_test)
|
||
db.commit()
|
||
|
||
request = create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="add_test",
|
||
test_id=str(new_test.id), justification="Coverage gap found in review",
|
||
)
|
||
db.commit()
|
||
|
||
approved = approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
|
||
assert approved.status == "approved"
|
||
|
||
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||
assert len(cts) == 2
|
||
assert any(ct.test_id == new_test.id for ct in cts)
|
||
|
||
def test_approve_remove_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||
request = create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="remove_test",
|
||
test_id=str(ct.test_id), justification="Test superseded",
|
||
)
|
||
db.commit()
|
||
|
||
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
|
||
|
||
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||
assert len(cts) == 0
|
||
|
||
def test_reject_request_leaves_campaign_untouched(self, db, active_campaign_with_test, admin_user):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||
request = create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="remove_test",
|
||
test_id=str(ct.test_id), justification="Test superseded",
|
||
)
|
||
db.commit()
|
||
|
||
rejected = reject_modification_request(
|
||
db, str(request.id), reviewer_id=admin_user.id, review_notes="Still needed for coverage",
|
||
)
|
||
assert rejected.status == "rejected"
|
||
assert rejected.review_notes == "Still needed for coverage"
|
||
|
||
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||
assert len(cts) == 1
|
||
|
||
def test_reject_without_notes_raises(self, db, active_campaign_with_test, admin_user):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||
request = create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="remove_test",
|
||
test_id=str(ct.test_id), justification="Test superseded",
|
||
)
|
||
db.commit()
|
||
with pytest.raises(BusinessRuleViolation, match="Review notes are required"):
|
||
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="")
|
||
|
||
def test_list_modification_requests_filters_by_status(self, db, active_campaign_with_test, admin_user):
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||
create_modification_request(
|
||
db, str(active_campaign_with_test.id),
|
||
requester_id=admin_user.id, action="remove_test",
|
||
test_id=str(ct.test_id), justification="Test superseded",
|
||
)
|
||
db.commit()
|
||
|
||
pending = list_modification_requests(db, status="pending")
|
||
assert len(pending) == 1
|
||
assert pending[0]["action"] == "remove_test"
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `pytest tests/test_campaign_approval.py -v -k Modification or DirectTestEditingGate`
|
||
Expected: FAIL — `ImportError` for the new functions, and the existing `add_test_to_campaign`/`remove_test_from_campaign` gate tests fail because those functions currently accept `active` campaigns too.
|
||
|
||
- [ ] **Step 3: Tighten the status gate on `add_test_to_campaign` and `remove_test_from_campaign`**
|
||
|
||
In `backend/app/services/campaign_crud_service.py`, change the `add_test_to_campaign` signature (around line 379) to accept an `allowed_statuses` keyword, defaulting to draft-only:
|
||
|
||
```python
|
||
def add_test_to_campaign(
|
||
db: Session,
|
||
campaign_id: str,
|
||
*,
|
||
test_id: str,
|
||
order_index: Optional[int] = None,
|
||
depends_on: Optional[str] = None,
|
||
phase: Optional[str] = None,
|
||
allowed_statuses: tuple[str, ...] = ("draft",),
|
||
) -> dict:
|
||
```
|
||
|
||
And change its status check (currently `if campaign.status not in ("draft", "active"):`) to:
|
||
|
||
```python
|
||
if campaign.status not in allowed_statuses:
|
||
raise BusinessRuleViolation(
|
||
f"Can only add tests to a campaign in one of: {', '.join(allowed_statuses)}"
|
||
)
|
||
```
|
||
|
||
Do the same for `remove_test_from_campaign` (around line 495): add `allowed_statuses: tuple[str, ...] = ("draft",)` to its signature, and change its check to:
|
||
|
||
```python
|
||
if campaign.status not in allowed_statuses:
|
||
raise BusinessRuleViolation(
|
||
f"Can only modify tests on a campaign in one of: {', '.join(allowed_statuses)}"
|
||
)
|
||
```
|
||
|
||
This is the only change to either function's body — everything else (order_index calculation, circular-dependency validation, phase inference, dependent-cleanup) stays exactly as-is.
|
||
|
||
- [ ] **Step 4: Add the modification-request functions**
|
||
|
||
Add the import for the new model at the top of `backend/app/services/campaign_crud_service.py` (change the existing campaign model import line):
|
||
|
||
```python
|
||
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
|
||
```
|
||
|
||
Append these functions at the end of the file:
|
||
|
||
```python
|
||
# ── Campaign Modification Requests ──────────────────────────────────────
|
||
|
||
|
||
def serialize_modification_request(db: Session, request: CampaignModificationRequest) -> dict:
|
||
"""Serialize a modification request, including the test's display name."""
|
||
test = request.test
|
||
return {
|
||
"id": str(request.id),
|
||
"campaign_id": str(request.campaign_id),
|
||
"campaign_name": request.campaign.name if request.campaign else None,
|
||
"requested_by": str(request.requested_by) if request.requested_by else None,
|
||
"action": request.action,
|
||
"test_id": str(request.test_id),
|
||
"test_name": test.name if test else None,
|
||
"order_index": request.order_index,
|
||
"phase": request.phase,
|
||
"justification": request.justification,
|
||
"status": request.status,
|
||
"reviewed_by": str(request.reviewed_by) if request.reviewed_by else None,
|
||
"reviewed_at": request.reviewed_at.isoformat() if request.reviewed_at else None,
|
||
"review_notes": request.review_notes,
|
||
"created_at": request.created_at.isoformat() if request.created_at else None,
|
||
}
|
||
|
||
|
||
def create_modification_request(
|
||
db: Session,
|
||
campaign_id: str,
|
||
*,
|
||
requester_id: uuid.UUID,
|
||
action: str,
|
||
test_id: str,
|
||
justification: str,
|
||
order_index: Optional[int] = None,
|
||
phase: Optional[str] = None,
|
||
) -> CampaignModificationRequest:
|
||
"""File a request to add/remove a test on an active campaign.
|
||
|
||
Raises EntityNotFoundError, BusinessRuleViolation.
|
||
Does not commit; caller commits.
|
||
"""
|
||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||
if not campaign:
|
||
raise EntityNotFoundError("Campaign", campaign_id)
|
||
|
||
if campaign.status != "active":
|
||
raise BusinessRuleViolation("Modification requests can only be created for active campaigns")
|
||
|
||
if action not in ("add_test", "remove_test"):
|
||
raise BusinessRuleViolation("action must be 'add_test' or 'remove_test'")
|
||
|
||
if not justification or not justification.strip():
|
||
raise BusinessRuleViolation("A justification is required")
|
||
|
||
test = db.query(Test).filter(Test.id == test_id).first()
|
||
if not test:
|
||
raise EntityNotFoundError("Test", test_id)
|
||
|
||
if action == "remove_test":
|
||
ct = (
|
||
db.query(CampaignTest)
|
||
.filter(CampaignTest.campaign_id == campaign_id, CampaignTest.test_id == test_id)
|
||
.first()
|
||
)
|
||
if not ct:
|
||
raise EntityNotFoundError("CampaignTest", test_id)
|
||
|
||
request = CampaignModificationRequest(
|
||
campaign_id=campaign_id,
|
||
requested_by=requester_id,
|
||
action=action,
|
||
test_id=test_id,
|
||
order_index=order_index,
|
||
phase=phase,
|
||
justification=justification.strip(),
|
||
status="pending",
|
||
)
|
||
db.add(request)
|
||
db.flush()
|
||
return request
|
||
|
||
|
||
def approve_modification_request(
|
||
db: Session,
|
||
request_id: str,
|
||
*,
|
||
reviewer_id: uuid.UUID,
|
||
) -> CampaignModificationRequest:
|
||
"""Approve a pending modification request and apply the underlying test change.
|
||
|
||
Raises EntityNotFoundError, BusinessRuleViolation.
|
||
Does not commit; caller commits.
|
||
"""
|
||
request = (
|
||
db.query(CampaignModificationRequest)
|
||
.filter(CampaignModificationRequest.id == request_id)
|
||
.first()
|
||
)
|
||
if not request:
|
||
raise EntityNotFoundError("CampaignModificationRequest", request_id)
|
||
|
||
if request.status != "pending":
|
||
raise BusinessRuleViolation("Only pending modification requests can be approved")
|
||
|
||
if request.action == "add_test":
|
||
add_test_to_campaign(
|
||
db, str(request.campaign_id),
|
||
test_id=str(request.test_id),
|
||
order_index=request.order_index,
|
||
phase=request.phase,
|
||
allowed_statuses=("active",),
|
||
)
|
||
else:
|
||
ct = (
|
||
db.query(CampaignTest)
|
||
.filter(
|
||
CampaignTest.campaign_id == request.campaign_id,
|
||
CampaignTest.test_id == request.test_id,
|
||
)
|
||
.first()
|
||
)
|
||
if not ct:
|
||
raise EntityNotFoundError("CampaignTest", str(request.test_id))
|
||
remove_test_from_campaign(
|
||
db, str(request.campaign_id), str(ct.id), allowed_statuses=("active",),
|
||
)
|
||
|
||
request.status = "approved"
|
||
request.reviewed_by = reviewer_id
|
||
request.reviewed_at = datetime.utcnow()
|
||
db.flush()
|
||
return request
|
||
|
||
|
||
def reject_modification_request(
|
||
db: Session,
|
||
request_id: str,
|
||
*,
|
||
reviewer_id: uuid.UUID,
|
||
review_notes: str,
|
||
) -> CampaignModificationRequest:
|
||
"""Reject a pending modification request. No change is applied to the campaign.
|
||
|
||
Raises EntityNotFoundError, BusinessRuleViolation.
|
||
Does not commit; caller commits.
|
||
"""
|
||
request = (
|
||
db.query(CampaignModificationRequest)
|
||
.filter(CampaignModificationRequest.id == request_id)
|
||
.first()
|
||
)
|
||
if not request:
|
||
raise EntityNotFoundError("CampaignModificationRequest", request_id)
|
||
|
||
if request.status != "pending":
|
||
raise BusinessRuleViolation("Only pending modification requests can be rejected")
|
||
|
||
if not review_notes or not review_notes.strip():
|
||
raise BusinessRuleViolation("Review notes are required to reject a modification request")
|
||
|
||
request.status = "rejected"
|
||
request.reviewed_by = reviewer_id
|
||
request.reviewed_at = datetime.utcnow()
|
||
request.review_notes = review_notes.strip()
|
||
db.flush()
|
||
return request
|
||
|
||
|
||
def list_modification_requests(
|
||
db: Session,
|
||
*,
|
||
campaign_id: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
) -> list[dict]:
|
||
"""List modification requests, optionally filtered by campaign and/or status."""
|
||
query = db.query(CampaignModificationRequest)
|
||
if campaign_id:
|
||
query = query.filter(CampaignModificationRequest.campaign_id == campaign_id)
|
||
if status:
|
||
query = query.filter(CampaignModificationRequest.status == status)
|
||
requests = query.order_by(CampaignModificationRequest.created_at.desc()).all()
|
||
return [serialize_modification_request(db, r) for r in requests]
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `pytest tests/test_campaign_approval.py -v`
|
||
Expected: PASS — all tests from Task 6 and Task 7 green.
|
||
|
||
- [ ] **Step 6: Run the full campaign test suite to check for regressions**
|
||
|
||
Run: `pytest tests/test_campaigns_and_snapshots.py -v`
|
||
Expected: PASS — the existing `test_circular_dependency_prevention` etc. tests still pass (they operate on draft campaigns, unaffected by the tightened gate).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval.py
|
||
git commit -m "feat(campaigns): gate test edits to draft, add modification request workflow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Service layer — campaign timeline
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/services/campaign_crud_service.py`
|
||
- Test: `backend/tests/test_campaign_approval.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `backend/tests/test_campaign_approval.py`:
|
||
|
||
```python
|
||
from app.services.audit_service import log_action
|
||
from app.services.campaign_crud_service import get_campaign_timeline
|
||
|
||
|
||
class TestCampaignTimeline:
|
||
def test_timeline_returns_entries_in_chronological_order(self, db, draft_campaign_with_test, admin_user):
|
||
log_action(
|
||
db, user_id=admin_user.id, action="create_campaign",
|
||
entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
|
||
)
|
||
log_action(
|
||
db, user_id=admin_user.id, action="submit_campaign_for_approval",
|
||
entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
|
||
)
|
||
db.commit()
|
||
|
||
timeline = get_campaign_timeline(db, str(draft_campaign_with_test.id))
|
||
assert len(timeline) == 2
|
||
assert timeline[0]["action"] == "create_campaign"
|
||
assert timeline[1]["action"] == "submit_campaign_for_approval"
|
||
|
||
def test_timeline_missing_campaign_raises(self, db):
|
||
import uuid as uuid_mod
|
||
with pytest.raises(EntityNotFoundError):
|
||
get_campaign_timeline(db, str(uuid_mod.uuid4()))
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `pytest tests/test_campaign_approval.py -v -k CampaignTimeline`
|
||
Expected: FAIL — `ImportError: cannot import name 'get_campaign_timeline'`.
|
||
|
||
- [ ] **Step 3: Implement `get_campaign_timeline`**
|
||
|
||
Add the import at the top of `backend/app/services/campaign_crud_service.py`:
|
||
|
||
```python
|
||
from app.models.audit import AuditLog
|
||
```
|
||
|
||
Append the function at the end of the file:
|
||
|
||
```python
|
||
def get_campaign_timeline(db: Session, campaign_id: str) -> list[dict]:
|
||
"""Return the chronological audit-log history for a campaign.
|
||
|
||
Raises EntityNotFoundError if the campaign does not exist.
|
||
"""
|
||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||
if not campaign:
|
||
raise EntityNotFoundError("Campaign", campaign_id)
|
||
|
||
logs = (
|
||
db.query(AuditLog)
|
||
.filter(AuditLog.entity_type == "campaign", AuditLog.entity_id == str(campaign_id))
|
||
.order_by(AuditLog.timestamp.asc())
|
||
.all()
|
||
)
|
||
|
||
return [
|
||
{
|
||
"id": str(log.id),
|
||
"action": log.action,
|
||
"user_id": str(log.user_id) if log.user_id else None,
|
||
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||
"details": log.details or {},
|
||
}
|
||
for log in logs
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `pytest tests/test_campaign_approval.py -v`
|
||
Expected: PASS — full file green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval.py
|
||
git commit -m "feat(campaigns): add get_campaign_timeline service function"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Router — submit / approve / reject endpoints
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/routers/campaigns.py`
|
||
- Test: `backend/tests/test_campaign_approval_router.py` (new file)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `backend/tests/test_campaign_approval_router.py`:
|
||
|
||
```python
|
||
"""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 _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 = client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=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 = client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=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)
|
||
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||
json={"start_date": "2026-09-01T00:00:00"},
|
||
headers=manager_headers,
|
||
)
|
||
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)
|
||
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||
json={"start_date": "2026-09-01T00:00:00"},
|
||
headers=red_lead_headers,
|
||
)
|
||
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)
|
||
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||
json={"start_date": "2026-09-01T00:00:00"},
|
||
headers=auth_headers,
|
||
)
|
||
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)
|
||
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/reject",
|
||
json={"reason": "Needs more detail"},
|
||
headers=manager_headers,
|
||
)
|
||
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)
|
||
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/reject",
|
||
json={"reason": ""},
|
||
headers=manager_headers,
|
||
)
|
||
assert resp.status_code == 400
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `pytest tests/test_campaign_approval_router.py -v`
|
||
Expected: FAIL — `404 Not Found` for `/submit`, `/approve`, `/reject` (routes don't exist yet).
|
||
|
||
- [ ] **Step 3: Add the Pydantic payloads and endpoints**
|
||
|
||
In `backend/app/routers/campaigns.py`, add the import for the new service functions (extend the existing multi-import block for `campaign_crud_service` — add right after the `activate_campaign as crud_activate` import around line 92):
|
||
|
||
```python
|
||
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,
|
||
)
|
||
```
|
||
|
||
Add two new Pydantic payloads near the other payload classes (after `SchedulePayload`, around line 173):
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
Add the three endpoints right after `update_campaign` (after line 386, before the `DELETE /campaigns/{id}` section):
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|
||
```
|
||
|
||
- [ ] **Step 4: Add `approved_by`, `approved_at`, `rejection_reason` to the serialized campaign**
|
||
|
||
In `serialize_campaign` in `backend/app/services/campaign_crud_service.py`, add these three keys right after `"completed_at"` (around line 127):
|
||
|
||
```python
|
||
"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,
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `pytest tests/test_campaign_approval_router.py -v`
|
||
Expected: PASS — all 7 tests green.
|
||
|
||
- [ ] **Step 6: Run the full backend test suite to check for regressions**
|
||
|
||
Run (from `backend/`): `pytest tests/ -v`
|
||
Expected: PASS — no pre-existing test broken. Pay special attention to `test_campaigns_and_snapshots.py`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/app/routers/campaigns.py backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval_router.py
|
||
git commit -m "feat(campaigns): submit/approve/reject router endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Router — modification request endpoints
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/routers/campaigns.py`
|
||
- Test: `backend/tests/test_campaign_approval_router.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `backend/tests/test_campaign_approval_router.py`:
|
||
|
||
```python
|
||
def _activate_campaign_directly(db, campaign):
|
||
"""Test helper — bypass the approval flow to get straight to 'active' for setup."""
|
||
campaign.status = "active"
|
||
db.commit()
|
||
db.refresh(campaign)
|
||
return campaign
|
||
|
||
|
||
def test_lead_can_create_modification_request(client, db, red_lead_user, red_lead_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
|
||
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||
db.add(tech)
|
||
db.flush()
|
||
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=red_lead_user.id)
|
||
db.add(new_test)
|
||
db.commit()
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||
json={"action": "add_test", "test_id": str(new_test.id), "justification": "Coverage gap"},
|
||
headers=red_lead_headers,
|
||
)
|
||
assert resp.status_code == 201
|
||
assert resp.json()["status"] == "pending"
|
||
|
||
|
||
def test_modification_request_without_justification_rejected(client, db, red_lead_user, red_lead_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": ""},
|
||
headers=red_lead_headers,
|
||
)
|
||
assert resp.status_code == 400
|
||
|
||
|
||
def test_manager_can_list_pending_modification_requests(client, db, red_lead_user, red_lead_headers, manager_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||
|
||
client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||
headers=red_lead_headers,
|
||
)
|
||
|
||
resp = client.get("/api/v1/campaigns/modification-requests/pending", headers=manager_headers)
|
||
assert resp.status_code == 200
|
||
assert len(resp.json()) == 1
|
||
|
||
|
||
def test_manager_can_approve_modification_request(client, db, red_lead_user, red_lead_headers, manager_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||
|
||
create_resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||
headers=red_lead_headers,
|
||
)
|
||
request_id = create_resp.json()["id"]
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
|
||
headers=manager_headers,
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["status"] == "approved"
|
||
|
||
remaining = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
|
||
assert len(remaining) == 0
|
||
|
||
|
||
def test_manager_can_reject_modification_request(client, db, red_lead_user, red_lead_headers, manager_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||
|
||
create_resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||
headers=red_lead_headers,
|
||
)
|
||
request_id = create_resp.json()["id"]
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/modification-requests/{request_id}/reject",
|
||
json={"review_notes": "Still needed"},
|
||
headers=manager_headers,
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["status"] == "rejected"
|
||
|
||
remaining = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
|
||
assert len(remaining) == 1
|
||
|
||
|
||
def test_lead_cannot_approve_modification_request(client, db, red_lead_user, red_lead_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||
|
||
create_resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||
headers=red_lead_headers,
|
||
)
|
||
request_id = create_resp.json()["id"]
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
|
||
headers=red_lead_headers,
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
def test_direct_add_test_blocked_on_active_campaign_via_router(client, db, red_lead_user, red_lead_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
_activate_campaign_directly(db, campaign)
|
||
|
||
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||
db.add(tech)
|
||
db.flush()
|
||
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=red_lead_user.id)
|
||
db.add(new_test)
|
||
db.commit()
|
||
|
||
resp = client.post(
|
||
f"/api/v1/campaigns/{campaign.id}/tests",
|
||
json={"test_id": str(new_test.id)},
|
||
headers=red_lead_headers,
|
||
)
|
||
assert resp.status_code == 400
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `pytest tests/test_campaign_approval_router.py -v -k modification`
|
||
Expected: FAIL — `404 Not Found` for the new routes (`test_direct_add_test_blocked...` should already pass since Task 7 tightened the service layer, but the route-level test confirms it end-to-end).
|
||
|
||
- [ ] **Step 3: Add the payloads and endpoints**
|
||
|
||
In `backend/app/routers/campaigns.py`, add the service imports (extend the multi-import block, same place as Task 9's imports):
|
||
|
||
```python
|
||
from app.services.campaign_crud_service import (
|
||
create_modification_request as crud_create_mod_request,
|
||
)
|
||
from app.services.campaign_crud_service import (
|
||
approve_modification_request as crud_approve_mod_request,
|
||
)
|
||
from app.services.campaign_crud_service import (
|
||
reject_modification_request as crud_reject_mod_request,
|
||
)
|
||
from app.services.campaign_crud_service import (
|
||
list_modification_requests as crud_list_mod_requests,
|
||
)
|
||
from app.services.campaign_crud_service import (
|
||
serialize_modification_request as crud_serialize_mod_request,
|
||
)
|
||
```
|
||
|
||
Add the payloads near `ApprovePayload`/`RejectPayload`:
|
||
|
||
```python
|
||
class ModificationRequestPayload(BaseModel):
|
||
"""Payload for a lead requesting to add/remove a test on an active campaign."""
|
||
|
||
action: str # add_test | remove_test
|
||
test_id: str
|
||
justification: str
|
||
order_index: Optional[int] = None
|
||
phase: Optional[str] = None
|
||
|
||
|
||
class RejectModificationPayload(BaseModel):
|
||
"""Payload for a manager rejecting a modification request."""
|
||
|
||
review_notes: str
|
||
```
|
||
|
||
Add the endpoints right after the `reject_campaign_endpoint` function from Task 9:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# POST /campaigns/{id}/modification-requests — Request a test add/remove
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.post("/{campaign_id}/modification-requests", status_code=201)
|
||
def create_modification_request_endpoint(
|
||
campaign_id: str,
|
||
payload: ModificationRequestPayload,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||
) -> dict:
|
||
"""File a request to add/remove a test on an active campaign — needs manager approval."""
|
||
with UnitOfWork(db) as uow:
|
||
request = crud_create_mod_request(
|
||
db, campaign_id,
|
||
requester_id=current_user.id,
|
||
action=payload.action,
|
||
test_id=payload.test_id,
|
||
justification=payload.justification,
|
||
order_index=payload.order_index,
|
||
phase=payload.phase,
|
||
)
|
||
log_action(
|
||
db,
|
||
user_id=current_user.id,
|
||
action="request_campaign_modification",
|
||
entity_type="campaign",
|
||
entity_id=campaign_id,
|
||
details={
|
||
"action": payload.action,
|
||
"test_id": payload.test_id,
|
||
"justification": payload.justification,
|
||
},
|
||
)
|
||
uow.commit()
|
||
return crud_serialize_mod_request(db, request)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /campaigns/{id}/modification-requests — List requests for one campaign
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/{campaign_id}/modification-requests")
|
||
def list_campaign_modification_requests_endpoint(
|
||
campaign_id: str,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
) -> list:
|
||
"""List modification requests filed against a specific campaign."""
|
||
return crud_list_mod_requests(db, campaign_id=campaign_id)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /campaigns/modification-requests/pending — Manager's global queue
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/modification-requests/pending")
|
||
def list_pending_modification_requests_endpoint(
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_any_role("manager")),
|
||
) -> list:
|
||
"""List all modification requests awaiting manager review, across all campaigns."""
|
||
return crud_list_mod_requests(db, status="pending")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST /campaigns/modification-requests/{id}/approve — Apply the requested change
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.post("/modification-requests/{request_id}/approve")
|
||
def approve_modification_request_endpoint(
|
||
request_id: str,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_any_role("manager")),
|
||
) -> dict:
|
||
"""Manager approves a modification request — the test change is applied now."""
|
||
with UnitOfWork(db) as uow:
|
||
request = crud_approve_mod_request(db, request_id, reviewer_id=current_user.id)
|
||
log_action(
|
||
db,
|
||
user_id=current_user.id,
|
||
action="approve_campaign_modification",
|
||
entity_type="campaign",
|
||
entity_id=str(request.campaign_id),
|
||
details={
|
||
"request_id": request_id,
|
||
"action": request.action,
|
||
"test_id": str(request.test_id),
|
||
},
|
||
)
|
||
uow.commit()
|
||
return crud_serialize_mod_request(db, request)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# POST /campaigns/modification-requests/{id}/reject — Deny the requested change
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.post("/modification-requests/{request_id}/reject")
|
||
def reject_modification_request_endpoint(
|
||
request_id: str,
|
||
payload: RejectModificationPayload,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_any_role("manager")),
|
||
) -> dict:
|
||
"""Manager rejects a modification request. No change is applied."""
|
||
with UnitOfWork(db) as uow:
|
||
request = crud_reject_mod_request(
|
||
db, request_id,
|
||
reviewer_id=current_user.id,
|
||
review_notes=payload.review_notes,
|
||
)
|
||
log_action(
|
||
db,
|
||
user_id=current_user.id,
|
||
action="reject_campaign_modification",
|
||
entity_type="campaign",
|
||
entity_id=str(request.campaign_id),
|
||
details={"request_id": request_id, "review_notes": payload.review_notes},
|
||
)
|
||
uow.commit()
|
||
return crud_serialize_mod_request(db, request)
|
||
```
|
||
|
||
Note: `GET /campaigns/modification-requests/pending` must be declared before `GET /campaigns/{campaign_id}` would otherwise be — but since that route has a different path segment count (2 segments vs. 1), there's no actual routing conflict regardless of declaration order in this file. Add these new routes in the order shown above for readability.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `pytest tests/test_campaign_approval_router.py -v`
|
||
Expected: PASS — all tests green, including `test_direct_add_test_blocked_on_active_campaign_via_router`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/routers/campaigns.py backend/tests/test_campaign_approval_router.py
|
||
git commit -m "feat(campaigns): modification-request router endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Router — timeline endpoint, admin-only manual activation, drop lead-set dates
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/routers/campaigns.py`
|
||
- Modify: `backend/app/services/campaign_service.py`
|
||
- Test: `backend/tests/test_campaign_approval_router.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `backend/tests/test_campaign_approval_router.py`:
|
||
|
||
```python
|
||
def test_get_campaign_timeline(client, db, red_lead_user, red_lead_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
|
||
|
||
resp = client.get(f"/api/v1/campaigns/{campaign.id}/timeline", headers=red_lead_headers)
|
||
assert resp.status_code == 200
|
||
actions = [e["action"] for e in resp.json()]
|
||
assert "submit_campaign_for_approval" in actions
|
||
|
||
|
||
def test_lead_cannot_directly_activate_draft_campaign(client, db, red_lead_user, red_lead_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
resp = client.post(f"/api/v1/campaigns/{campaign.id}/activate", headers=red_lead_headers)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
def test_admin_can_still_directly_activate_as_override(client, db, red_lead_user, red_lead_headers, auth_headers):
|
||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||
resp = client.post(f"/api/v1/campaigns/{campaign.id}/activate", headers=auth_headers)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["status"] == "active"
|
||
|
||
|
||
def test_create_campaign_payload_has_no_start_date_field(client, db, red_lead_headers):
|
||
resp = client.post(
|
||
"/api/v1/campaigns",
|
||
json={"name": "No date campaign", "start_date": "2026-01-01"},
|
||
headers=red_lead_headers,
|
||
)
|
||
assert resp.status_code == 201
|
||
# start_date silently ignored — only the manager can ever set it, via /approve
|
||
assert resp.json()["start_date"] is None
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `pytest tests/test_campaign_approval_router.py -v -k "timeline or activate or start_date"`
|
||
Expected: FAIL — `404` for `/timeline`, `403`-expecting tests get `200` (lead can still activate directly today), and the create-campaign test gets a non-null `start_date`.
|
||
|
||
- [ ] **Step 3: Add the timeline endpoint**
|
||
|
||
Import the new service function in `backend/app/routers/campaigns.py` (extend the multi-import block):
|
||
|
||
```python
|
||
from app.services.campaign_crud_service import (
|
||
get_campaign_timeline as crud_get_timeline,
|
||
)
|
||
```
|
||
|
||
Add the endpoint right after `get_campaign_history` (after line 856):
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/{campaign_id}/timeline")
|
||
def get_campaign_timeline_endpoint(
|
||
campaign_id: str,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
) -> list:
|
||
"""Return the chronological audit-log history for a campaign."""
|
||
return crud_get_timeline(db, campaign_id)
|
||
```
|
||
|
||
- [ ] **Step 4: Restrict direct `/activate` to admin only**
|
||
|
||
In `backend/app/routers/campaigns.py`, change the `activate_campaign` endpoint's dependency (around line 516) from:
|
||
|
||
```python
|
||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||
```
|
||
|
||
to:
|
||
|
||
```python
|
||
current_user: User = Depends(require_any_role("admin")),
|
||
```
|
||
|
||
This closes the loophole where a lead could bypass the approval queue entirely by calling the old direct-activation endpoint. `require_any_role("admin")` only ever lets `admin` through (per its own always-pass rule), so this becomes an admin-only emergency override, matching the "admin also approves" pattern used for `/approve` and `/reject`.
|
||
|
||
- [ ] **Step 5: Remove `start_date` from lead-facing create/update/generate payloads**
|
||
|
||
In `backend/app/routers/campaigns.py`:
|
||
|
||
1. Remove the `start_date` field from `CampaignCreate` (delete the line `start_date: Optional[str] = None # ISO date...` around line 127).
|
||
2. Remove the `start_date` field from `CampaignUpdate` (delete the equivalent line around line 146).
|
||
3. Remove `start_date: Optional[str] = None` from `GenerateFromActorPayload` (around line 696) — replace the class body with `pass`:
|
||
|
||
```python
|
||
class GenerateFromActorPayload(BaseModel):
|
||
pass
|
||
```
|
||
|
||
4. In the `generate_campaign_from_actor` endpoint, remove the two lines that parse and forward `payload.start_date`:
|
||
|
||
```python
|
||
campaign = generate_campaign_from_threat_actor(
|
||
db,
|
||
uuid.UUID(actor_id),
|
||
current_user,
|
||
)
|
||
```
|
||
|
||
(delete the `start_date_parsed = ...` line entirely and the `start_date=start_date_parsed` kwarg).
|
||
|
||
In `backend/app/services/campaign_service.py`, remove the `start_date` parameter from `generate_campaign_from_threat_actor`'s signature and drop `start_date=start_date` from the `Campaign(...)` construction inside it (around lines 183 and 241).
|
||
|
||
In `backend/app/services/campaign_crud_service.py`, remove the `start_date` parameter from `create_campaign`'s signature and drop `start_date=datetime.fromisoformat(start_date) if start_date else None,` from its `Campaign(...)` construction (around lines 278 and 299). Also remove the `start_date` handling block from `update_campaign` (the `if "start_date" in fields and fields["start_date"]:` block around line 364) — with `start_date` no longer in the request schema this is dead code, but removing it makes clear the field is intentionally unreachable via this function now.
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `pytest tests/test_campaign_approval_router.py -v`
|
||
Expected: PASS — all tests in the file green.
|
||
|
||
- [ ] **Step 7: Run the full backend test suite**
|
||
|
||
Run (from `backend/`): `pytest tests/ -v`
|
||
Expected: PASS. If `test_campaigns_and_snapshots.py` or any other file passes `start_date` to `create_campaign`/`generate_campaign_from_threat_actor` directly, update that call site to drop the argument — grep first:
|
||
|
||
Run: `grep -rn "start_date=" backend/tests/ backend/app/services/campaign_scheduler_service.py`
|
||
|
||
Fix any remaining call sites the grep turns up (the scheduler's `_clone_campaign` does not set `start_date` today, so it should need no change).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add backend/app/routers/campaigns.py backend/app/services/campaign_service.py backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval_router.py
|
||
git commit -m "feat(campaigns): timeline endpoint, admin-only manual activation, remove lead-set dates"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Backend — full-suite regression check
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [ ] **Step 1: Run the complete backend test suite**
|
||
|
||
Run (from `backend/`): `pytest tests/ -v`
|
||
Expected: PASS — every test green, including all new files from Tasks 1–11.
|
||
|
||
- [ ] **Step 2: Run Ruff lint**
|
||
|
||
Run (from `backend/`): `ruff check app/ tests/`
|
||
Expected: no errors. Fix any import-order or unused-import issues the edits introduced (e.g. if `Optional` or `datetime` end up unused anywhere after the `start_date` removal in Task 11).
|
||
|
||
- [ ] **Step 3: Commit any lint fixes**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore(campaigns): fix lint issues from approval workflow"
|
||
```
|
||
|
||
(Skip this commit if step 2 found nothing to fix.)
|
||
|
||
---
|
||
|
||
## Task 13: Frontend — API client for the approval workflow
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api/campaigns.ts`
|
||
|
||
- [ ] **Step 1: Add new types and drop `start_date` from create/update payloads**
|
||
|
||
In `frontend/src/api/campaigns.ts`:
|
||
|
||
1. Add `approved_by`, `approved_at`, `rejection_reason` to the `Campaign` interface, right after `completed_at`:
|
||
|
||
```typescript
|
||
approved_by: string | null;
|
||
approved_at: string | null;
|
||
rejection_reason: string | null;
|
||
```
|
||
|
||
2. Remove `start_date` from `CampaignCreatePayload` (it's now ignored by the backend and only ever settable via `approveCampaign`):
|
||
|
||
```typescript
|
||
export interface CampaignCreatePayload {
|
||
name: string;
|
||
description?: string;
|
||
type?: string;
|
||
threat_actor_id?: string;
|
||
target_platform?: string;
|
||
tags?: string[];
|
||
scheduled_at?: string;
|
||
}
|
||
```
|
||
|
||
3. Add the modification-request type and campaign timeline entry type at the end of the "Types" section:
|
||
|
||
```typescript
|
||
export type ModificationRequestAction = "add_test" | "remove_test";
|
||
export type ModificationRequestStatus = "pending" | "approved" | "rejected";
|
||
|
||
export interface CampaignModificationRequest {
|
||
id: string;
|
||
campaign_id: string;
|
||
campaign_name: string | null;
|
||
requested_by: string | null;
|
||
action: ModificationRequestAction;
|
||
test_id: string;
|
||
test_name: string | null;
|
||
order_index: number | null;
|
||
phase: string | null;
|
||
justification: string;
|
||
status: ModificationRequestStatus;
|
||
reviewed_by: string | null;
|
||
reviewed_at: string | null;
|
||
review_notes: string | null;
|
||
created_at: string | null;
|
||
}
|
||
|
||
export interface CampaignTimelineEntry {
|
||
id: string;
|
||
action: string;
|
||
user_id: string | null;
|
||
timestamp: string;
|
||
details: Record<string, unknown>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the API functions**
|
||
|
||
Append to `frontend/src/api/campaigns.ts`:
|
||
|
||
```typescript
|
||
// ── Approval workflow ──────────────────────────────────────────────
|
||
|
||
/** Submit a draft campaign into the manager's approval queue. */
|
||
export async function submitCampaignForApproval(campaignId: string): Promise<Campaign> {
|
||
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/submit`);
|
||
return data;
|
||
}
|
||
|
||
/** Manager approves a pending campaign, fixing its start date. */
|
||
export async function approveCampaign(campaignId: string, startDate: string): Promise<Campaign> {
|
||
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/approve`, {
|
||
start_date: startDate,
|
||
});
|
||
return data;
|
||
}
|
||
|
||
/** Manager rejects a pending campaign with a mandatory reason. */
|
||
export async function rejectCampaign(campaignId: string, reason: string): Promise<Campaign> {
|
||
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/reject`, { reason });
|
||
return data;
|
||
}
|
||
|
||
// ── Modification requests ───────────────────────────────────────────
|
||
|
||
/** File a request to add/remove a test on an active campaign. */
|
||
export async function createModificationRequest(
|
||
campaignId: string,
|
||
payload: {
|
||
action: ModificationRequestAction;
|
||
test_id: string;
|
||
justification: string;
|
||
order_index?: number;
|
||
phase?: string;
|
||
},
|
||
): Promise<CampaignModificationRequest> {
|
||
const { data } = await client.post<CampaignModificationRequest>(
|
||
`/campaigns/${campaignId}/modification-requests`,
|
||
payload,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
/** List modification requests filed against a specific campaign. */
|
||
export async function listCampaignModificationRequests(
|
||
campaignId: string,
|
||
): Promise<CampaignModificationRequest[]> {
|
||
const { data } = await client.get<CampaignModificationRequest[]>(
|
||
`/campaigns/${campaignId}/modification-requests`,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
/** Manager's global queue of modification requests awaiting review. */
|
||
export async function listPendingModificationRequests(): Promise<CampaignModificationRequest[]> {
|
||
const { data } = await client.get<CampaignModificationRequest[]>(
|
||
"/campaigns/modification-requests/pending",
|
||
);
|
||
return data;
|
||
}
|
||
|
||
/** Manager approves a modification request — the test change is applied now. */
|
||
export async function approveModificationRequest(
|
||
requestId: string,
|
||
): Promise<CampaignModificationRequest> {
|
||
const { data } = await client.post<CampaignModificationRequest>(
|
||
`/campaigns/modification-requests/${requestId}/approve`,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
/** Manager rejects a modification request with mandatory review notes. */
|
||
export async function rejectModificationRequest(
|
||
requestId: string,
|
||
reviewNotes: string,
|
||
): Promise<CampaignModificationRequest> {
|
||
const { data } = await client.post<CampaignModificationRequest>(
|
||
`/campaigns/modification-requests/${requestId}/reject`,
|
||
{ review_notes: reviewNotes },
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// ── Timeline ─────────────────────────────────────────────────────────
|
||
|
||
/** Get the audit-log timeline for a campaign. */
|
||
export async function getCampaignTimeline(campaignId: string): Promise<CampaignTimelineEntry[]> {
|
||
const { data } = await client.get<CampaignTimelineEntry[]>(`/campaigns/${campaignId}/timeline`);
|
||
return data;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TypeScript compiles**
|
||
|
||
Run (from `frontend/`): `npm run build`
|
||
Expected: build succeeds. It's expected/fine that `CampaignsPage.tsx` and `CampaignDetailPage.tsx` still reference `newCampaign.start_date` — those get fixed in Tasks 14–15. If `npm run build` fails only on those two files' `start_date` usage, that confirms the type change took effect correctly; proceed to Task 14.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/campaigns.ts
|
||
git commit -m "feat(campaigns): API client for approval workflow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Frontend — CampaignsPage: drop lead-set start dates, add pending-approval filter
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/CampaignsPage.tsx`
|
||
|
||
- [ ] **Step 1: Remove the "Start date" field from the Create Campaign modal**
|
||
|
||
In `frontend/src/pages/CampaignsPage.tsx`:
|
||
|
||
1. Remove `start_date: ""` from the `newCampaign` state initializer (line 58) and from the `setNewCampaign` reset call in `createMutation.onSuccess` (line 78):
|
||
|
||
```typescript
|
||
const [newCampaign, setNewCampaign] = useState({
|
||
name: "",
|
||
description: "",
|
||
type: "custom",
|
||
target_platform: "",
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
|
||
```
|
||
|
||
2. Delete the entire "Start date" `<div className="mt-4">...</div>` block (lines 232–253) from the Create Campaign modal.
|
||
|
||
- [ ] **Step 2: Remove the "Start date" field from the Generate-from-Threat-Actor modal**
|
||
|
||
Remove the `actorStartDate` state (line 52) and its setter usages:
|
||
|
||
```typescript
|
||
const [actorSearch, setActorSearch] = useState("");
|
||
```
|
||
|
||
(delete the `actorStartDate` line entirely)
|
||
|
||
Update `generateMutation` (lines 90–99) to stop passing a start date:
|
||
|
||
```typescript
|
||
const generateMutation = useMutation({
|
||
mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId),
|
||
onSuccess: (campaign) => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||
setShowActorSelector(false);
|
||
navigate(`/campaigns/${campaign.id}`);
|
||
},
|
||
});
|
||
```
|
||
|
||
Delete the entire "Start date" `<div className="mb-4">...</div>` block from the actor selector modal (the one right after the search input, roughly lines 295–316). The `setActorStartDate("")` call that used to live in `generateMutation`'s `onSuccess` is already gone since Step 2 rewrote that whole mutation block above — no other call sites reference `actorStartDate` after this change.
|
||
|
||
- [ ] **Step 3: Add a "Pending Approval" option to the status filter**
|
||
|
||
In the status `<select>` (around line 166), add the option:
|
||
|
||
```typescript
|
||
<option value="draft">Draft</option>
|
||
<option value="pending_approval">Pending Approval</option>
|
||
<option value="active">Active</option>
|
||
```
|
||
|
||
- [ ] **Step 4: Add a status color/label for `pending_approval`**
|
||
|
||
In the `statusColors` map at the top of the file (line 18), add:
|
||
|
||
```typescript
|
||
const statusColors: Record<string, string> = {
|
||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||
pending_approval: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||
active: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
|
||
completed: "bg-green-900/50 text-green-400 border-green-500/30",
|
||
archived: "bg-gray-800/50 text-gray-500 border-gray-700/30",
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Update `generateCampaignFromThreatActor`'s API signature usage**
|
||
|
||
Confirm `generateCampaignFromThreatActor` in `frontend/src/api/campaigns.ts` still accepts an optional second argument (it does, from before this plan) — the call site from Step 2 simply omits it now, which is valid since the parameter is optional. No further change needed there.
|
||
|
||
- [ ] **Step 6: Verify TypeScript compiles**
|
||
|
||
Run (from `frontend/`): `npm run build`
|
||
Expected: build succeeds with no errors referencing `CampaignsPage.tsx`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/CampaignsPage.tsx
|
||
git commit -m "feat(campaigns): remove lead-set start dates, add pending-approval filter"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Frontend — CampaignDetailPage: submit / approve / reject UI
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/CampaignDetailPage.tsx`
|
||
|
||
- [ ] **Step 1: Import the new API functions and add `manager` to the role checks**
|
||
|
||
In `frontend/src/pages/CampaignDetailPage.tsx`, update the import block (lines 20–30):
|
||
|
||
```typescript
|
||
import {
|
||
getCampaign,
|
||
activateCampaign,
|
||
completeCampaign,
|
||
deleteCampaign,
|
||
removeTestFromCampaign,
|
||
scheduleCampaign,
|
||
getCampaignHistory,
|
||
submitCampaignForApproval,
|
||
approveCampaign,
|
||
rejectCampaign,
|
||
type Campaign,
|
||
type CampaignHistoryEntry,
|
||
} from "../api/campaigns";
|
||
```
|
||
|
||
Update the role checks (lines 79–81):
|
||
|
||
```typescript
|
||
const role = user?.role ?? "";
|
||
const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
|
||
const canComplete = role === "admin" || role === "red_lead";
|
||
const isManager = role === "admin" || role === "manager";
|
||
```
|
||
|
||
- [ ] **Step 2: Add state and mutations for submit/approve/reject**
|
||
|
||
Add right after the `startDateWarning` state (line 72):
|
||
|
||
```typescript
|
||
const [approveModalOpen, setApproveModalOpen] = useState(false);
|
||
const [approveStartDate, setApproveStartDate] = useState("");
|
||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||
const [rejectReason, setRejectReason] = useState("");
|
||
```
|
||
|
||
Add right after `activateMutation` (after line 120):
|
||
|
||
```typescript
|
||
const submitMutation = useMutation({
|
||
mutationFn: () => submitCampaignForApproval(campaignId!),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
|
||
showToast("Campaign submitted for approval", "success");
|
||
},
|
||
onError: (err: Error) => showToast(err.message, "error"),
|
||
});
|
||
|
||
const approveMutation = useMutation({
|
||
mutationFn: () => approveCampaign(campaignId!, approveStartDate),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
|
||
setApproveModalOpen(false);
|
||
setApproveStartDate("");
|
||
showToast("Campaign approved and activated", "success");
|
||
},
|
||
onError: (err: Error) => showToast(err.message, "error"),
|
||
});
|
||
|
||
const rejectMutation = useMutation({
|
||
mutationFn: () => rejectCampaign(campaignId!, rejectReason),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
|
||
setRejectModalOpen(false);
|
||
setRejectReason("");
|
||
showToast("Campaign rejected", "success");
|
||
},
|
||
onError: (err: Error) => showToast(err.message, "error"),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Replace the "Activate" button with "Submit for Approval" (lead) and add Approve/Reject buttons (manager)**
|
||
|
||
Replace the existing activate button block (lines 320–333):
|
||
|
||
```typescript
|
||
{canManage && campaign.status === "draft" && (
|
||
<button
|
||
onClick={() => submitMutation.mutate()}
|
||
disabled={submitMutation.isPending}
|
||
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
||
>
|
||
{submitMutation.isPending ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Play className="h-4 w-4" />
|
||
)}
|
||
Submit for Approval
|
||
</button>
|
||
)}
|
||
{isManager && campaign.status === "pending_approval" && (
|
||
<>
|
||
<button
|
||
onClick={() => setRejectModalOpen(true)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
||
>
|
||
Reject
|
||
</button>
|
||
<button
|
||
onClick={() => setApproveModalOpen(true)}
|
||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 transition-colors"
|
||
>
|
||
<CheckCircle className="h-4 w-4" />
|
||
Approve
|
||
</button>
|
||
</>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 4: Show the rejection reason banner when a campaign was rejected**
|
||
|
||
Add right after the campaign description block, inside the header (after the `{campaign.description && (...)}` block, around line 264):
|
||
|
||
```typescript
|
||
{campaign.status === "draft" && campaign.rejection_reason && (
|
||
<div className="mt-2 rounded-lg border border-amber-500/30 bg-amber-900/20 px-3 py-2 text-xs text-amber-400">
|
||
Rejected by manager: {campaign.rejection_reason}
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 5: Add the Approve and Reject modals**
|
||
|
||
Add right before the "Toast notification" block (before line 723):
|
||
|
||
```typescript
|
||
{/* Approve modal */}
|
||
{approveModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||
<div className="mx-4 w-full max-w-md rounded-xl border border-green-500/30 bg-gray-900 p-6 shadow-2xl">
|
||
<h3 className="mb-4 text-lg font-semibold text-white">Approve Campaign</h3>
|
||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||
Start date <span className="text-red-400">*</span>
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={approveStartDate}
|
||
onChange={(e) => setApproveStartDate(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none [color-scheme:dark]"
|
||
/>
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setApproveModalOpen(false)}
|
||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => approveMutation.mutate()}
|
||
disabled={!approveStartDate || approveMutation.isPending}
|
||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50 transition-colors"
|
||
>
|
||
{approveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
Approve & Activate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reject modal */}
|
||
{rejectModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||
<div className="mx-4 w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-6 shadow-2xl">
|
||
<h3 className="mb-4 text-lg font-semibold text-white">Reject Campaign</h3>
|
||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||
Reason <span className="text-red-400">*</span>
|
||
</label>
|
||
<textarea
|
||
value={rejectReason}
|
||
onChange={(e) => setRejectReason(e.target.value)}
|
||
rows={3}
|
||
placeholder="Explain what needs to change before this can be approved…"
|
||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||
/>
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setRejectModalOpen(false)}
|
||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => rejectMutation.mutate()}
|
||
disabled={!rejectReason.trim() || rejectMutation.isPending}
|
||
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||
>
|
||
{rejectMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
Reject
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify TypeScript compiles**
|
||
|
||
Run (from `frontend/`): `npm run build`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 7: Manual verification in the browser**
|
||
|
||
Start the dev server (`preview_start` with the frontend config, or `npm run dev` inside `frontend/`). As a `red_lead` user, create a draft campaign, add a test, click "Submit for Approval," confirm the status badge changes to `pending_approval` and the Activate button is gone. Log in as a `manager` (create one via Users page if needed) and confirm the Approve/Reject buttons appear, that Approve requires a date, and that Reject requires a reason and returns the campaign to `draft` with the banner visible.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/CampaignDetailPage.tsx
|
||
git commit -m "feat(campaigns): submit/approve/reject UI on campaign detail page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Frontend — modification request UI
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/RequestCampaignModificationModal.tsx`
|
||
- Modify: `frontend/src/pages/CampaignDetailPage.tsx`
|
||
|
||
- [ ] **Step 1: Create the request modal component**
|
||
|
||
Create `frontend/src/components/RequestCampaignModificationModal.tsx`:
|
||
|
||
```typescript
|
||
import { useState } from "react";
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { X, Loader2 } from "lucide-react";
|
||
import { createModificationRequest, type CampaignTest } from "../api/campaigns";
|
||
|
||
interface Props {
|
||
campaignId: string;
|
||
tests: CampaignTest[];
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
export default function RequestCampaignModificationModal({
|
||
campaignId,
|
||
tests,
|
||
open,
|
||
onClose,
|
||
onSuccess,
|
||
}: Props) {
|
||
const queryClient = useQueryClient();
|
||
const [action, setAction] = useState<"add_test" | "remove_test">("remove_test");
|
||
const [testId, setTestId] = useState("");
|
||
const [justification, setJustification] = useState("");
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: () =>
|
||
createModificationRequest(campaignId, {
|
||
action,
|
||
test_id: testId,
|
||
justification: justification.trim(),
|
||
}),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
|
||
setTestId("");
|
||
setJustification("");
|
||
onSuccess();
|
||
onClose();
|
||
},
|
||
});
|
||
|
||
if (!open) return null;
|
||
|
||
const canSubmit = testId && justification.trim().length > 0 && !mutation.isPending;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-white">Request Campaign Modification</h2>
|
||
<button onClick={onClose} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white">
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Action</label>
|
||
<select
|
||
value={action}
|
||
onChange={(e) => setAction(e.target.value as "add_test" | "remove_test")}
|
||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||
>
|
||
<option value="remove_test">Remove a test from this campaign</option>
|
||
<option value="add_test">Add an existing test to this campaign</option>
|
||
</select>
|
||
</div>
|
||
|
||
{action === "remove_test" ? (
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Test to remove</label>
|
||
<select
|
||
value={testId}
|
||
onChange={(e) => setTestId(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||
>
|
||
<option value="">Select a test…</option>
|
||
{tests.map((t) => (
|
||
<option key={t.test_id} value={t.test_id}>
|
||
{t.test_name || t.test_id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Test ID to add</label>
|
||
<input
|
||
value={testId}
|
||
onChange={(e) => setTestId(e.target.value)}
|
||
placeholder="Paste the existing test's ID"
|
||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||
Justification <span className="text-red-400">*</span>
|
||
</label>
|
||
<textarea
|
||
value={justification}
|
||
onChange={(e) => setJustification(e.target.value)}
|
||
rows={3}
|
||
placeholder="Why does this campaign need to change?"
|
||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{mutation.isError && (
|
||
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
||
{(mutation.error as Error)?.message || "Failed to submit request"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button
|
||
onClick={onClose}
|
||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => mutation.mutate()}
|
||
disabled={!canSubmit}
|
||
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
||
>
|
||
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
Submit Request
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the modal and the pending-requests panel into CampaignDetailPage**
|
||
|
||
In `frontend/src/pages/CampaignDetailPage.tsx`, add imports:
|
||
|
||
```typescript
|
||
import {
|
||
listCampaignModificationRequests,
|
||
approveModificationRequest,
|
||
rejectModificationRequest,
|
||
} from "../api/campaigns";
|
||
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
|
||
```
|
||
|
||
Add state (near `showAddTestModal`):
|
||
|
||
```typescript
|
||
const [showModRequestModal, setShowModRequestModal] = useState(false);
|
||
```
|
||
|
||
Add the query and mutations (near `historyData`'s query):
|
||
|
||
```typescript
|
||
const { data: modRequests = [] } = useQuery({
|
||
queryKey: ["campaign-modification-requests", campaignId],
|
||
queryFn: () => listCampaignModificationRequests(campaignId!),
|
||
enabled: !!campaignId,
|
||
});
|
||
|
||
const approveModRequestMutation = useMutation({
|
||
mutationFn: (requestId: string) => approveModificationRequest(requestId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
|
||
showToast("Modification approved and applied", "success");
|
||
},
|
||
onError: (err: Error) => showToast(err.message, "error"),
|
||
});
|
||
|
||
const rejectModRequestMutation = useMutation({
|
||
mutationFn: ({ requestId, notes }: { requestId: string; notes: string }) =>
|
||
rejectModificationRequest(requestId, notes),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
|
||
showToast("Modification rejected", "success");
|
||
},
|
||
onError: (err: Error) => showToast(err.message, "error"),
|
||
});
|
||
```
|
||
|
||
Add a "Request Modification" button next to the Tests table header, right after the existing `{canManage && campaign.status === "draft" && (...)}` Add Test button (around line 585):
|
||
|
||
```typescript
|
||
{canManage && campaign.status === "active" && (
|
||
<button
|
||
onClick={() => setShowModRequestModal(true)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors"
|
||
>
|
||
Request Modification
|
||
</button>
|
||
)}
|
||
```
|
||
|
||
Add a pending-modification-requests panel right after the Tests Table section closes (after line 677, before the Jira panel at line 680):
|
||
|
||
```typescript
|
||
{/* Modification Requests */}
|
||
{modRequests.length > 0 && (
|
||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||
<h2 className="mb-4 text-lg font-semibold text-white">Modification Requests</h2>
|
||
<div className="space-y-3">
|
||
{modRequests.map((r) => (
|
||
<div key={r.id} className="rounded-lg border border-gray-800 p-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm text-gray-200">
|
||
<span className="font-medium capitalize">{r.action.replace("_", " ")}</span>
|
||
{" — "}
|
||
{r.test_name || r.test_id}
|
||
</p>
|
||
<p className="mt-1 text-xs text-gray-400">{r.justification}</p>
|
||
{r.review_notes && (
|
||
<p className="mt-1 text-xs text-amber-400">Manager notes: {r.review_notes}</p>
|
||
)}
|
||
</div>
|
||
<span
|
||
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
||
r.status === "pending"
|
||
? "border-amber-500/30 bg-amber-900/30 text-amber-400"
|
||
: r.status === "approved"
|
||
? "border-green-500/30 bg-green-900/30 text-green-400"
|
||
: "border-red-500/30 bg-red-900/30 text-red-400"
|
||
}`}
|
||
>
|
||
{r.status}
|
||
</span>
|
||
</div>
|
||
{isManager && r.status === "pending" && (
|
||
<div className="mt-3 flex gap-2">
|
||
<button
|
||
onClick={() => approveModRequestMutation.mutate(r.id)}
|
||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-500 transition-colors"
|
||
>
|
||
Approve
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const notes = window.prompt("Reason for rejecting this request:");
|
||
if (notes && notes.trim()) {
|
||
rejectModRequestMutation.mutate({ requestId: r.id, notes: notes.trim() });
|
||
}
|
||
}}
|
||
className="rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
||
>
|
||
Reject
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Request Modification Modal */}
|
||
<RequestCampaignModificationModal
|
||
campaignId={campaignId!}
|
||
tests={campaign.tests}
|
||
open={showModRequestModal}
|
||
onClose={() => setShowModRequestModal(false)}
|
||
onSuccess={() => showToast("Modification request submitted", "success")}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TypeScript compiles**
|
||
|
||
Run (from `frontend/`): `npm run build`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 4: Manual verification in the browser**
|
||
|
||
As a `red_lead` on an `active` campaign, click "Request Modification," pick "Remove a test," fill justification, submit. Confirm the test is still in the Tests table (unchanged) and a new "pending" entry appears under Modification Requests. Log in as `manager`, click Approve on that entry, confirm the test disappears from the Tests table and the request shows "approved."
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/RequestCampaignModificationModal.tsx frontend/src/pages/CampaignDetailPage.tsx
|
||
git commit -m "feat(campaigns): modification request UI on campaign detail page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Frontend — timeline tab and role list updates
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/CampaignAuditTimeline.tsx`
|
||
- Modify: `frontend/src/pages/CampaignDetailPage.tsx`
|
||
- Modify: `frontend/src/pages/UsersPage.tsx`
|
||
- Modify: `frontend/src/pages/SettingsPage.tsx`
|
||
|
||
- [ ] **Step 1: Create the audit timeline component**
|
||
|
||
Create `frontend/src/components/CampaignAuditTimeline.tsx`:
|
||
|
||
```typescript
|
||
import { Clock, Loader2 } from "lucide-react";
|
||
import type { CampaignTimelineEntry } from "../api/campaigns";
|
||
|
||
interface Props {
|
||
entries: CampaignTimelineEntry[];
|
||
isLoading: boolean;
|
||
}
|
||
|
||
const ACTION_LABELS: Record<string, string> = {
|
||
create_campaign: "Campaign created",
|
||
update_campaign: "Campaign updated",
|
||
submit_campaign_for_approval: "Submitted for approval",
|
||
approve_campaign: "Approved by manager",
|
||
reject_campaign: "Rejected by manager",
|
||
activate_campaign: "Activated",
|
||
complete_campaign: "Marked completed",
|
||
request_campaign_modification: "Modification requested",
|
||
approve_campaign_modification: "Modification approved",
|
||
reject_campaign_modification: "Modification rejected",
|
||
};
|
||
|
||
export default function CampaignAuditTimeline({ entries, isLoading }: Props) {
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (entries.length === 0) {
|
||
return (
|
||
<div className="py-8 text-center">
|
||
<Clock className="mx-auto h-8 w-8 text-gray-600" />
|
||
<p className="mt-2 text-sm text-gray-400">No timeline events yet</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const formatDate = (d: string) =>
|
||
new Date(d).toLocaleString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
});
|
||
|
||
return (
|
||
<div className="relative space-y-0">
|
||
<div className="absolute left-4 top-2 bottom-2 w-px bg-gray-700" />
|
||
{entries.map((entry, idx) => (
|
||
<div key={entry.id || idx} className="relative flex gap-4 py-3">
|
||
<div className="z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-700 bg-gray-800">
|
||
<Clock className="h-3.5 w-3.5 text-gray-400" />
|
||
</div>
|
||
<div className="flex-1 pt-0.5">
|
||
<p className="text-sm font-medium text-gray-200">
|
||
{ACTION_LABELS[entry.action] || entry.action}
|
||
</p>
|
||
<p className="text-xs text-gray-500">{formatDate(entry.timestamp)}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the timeline into CampaignDetailPage**
|
||
|
||
Add imports:
|
||
|
||
```typescript
|
||
import { getCampaignTimeline } from "../api/campaigns";
|
||
import CampaignAuditTimeline from "../components/CampaignAuditTimeline";
|
||
```
|
||
|
||
Add the query (near `historyData`):
|
||
|
||
```typescript
|
||
const { data: timeline = [], isLoading: isTimelineLoading } = useQuery({
|
||
queryKey: ["campaign-timeline", campaignId],
|
||
queryFn: () => getCampaignTimeline(campaignId!),
|
||
enabled: !!campaignId,
|
||
});
|
||
```
|
||
|
||
Add a Timeline section right after the Modification Requests panel added in Task 16 (or right before the Jira panel if Task 16 wasn't applicable):
|
||
|
||
```typescript
|
||
{/* Timeline */}
|
||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||
<h2 className="mb-4 text-lg font-semibold text-white">Timeline</h2>
|
||
<CampaignAuditTimeline entries={timeline} isLoading={isTimelineLoading} />
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Add `manager` to the Users page role list**
|
||
|
||
In `frontend/src/pages/UsersPage.tsx`, update `ROLES` (line 17):
|
||
|
||
```typescript
|
||
const ROLES = [
|
||
{ value: "viewer", label: "Viewer" },
|
||
{ value: "red_tech", label: "Red Tech" },
|
||
{ value: "blue_tech", label: "Blue Tech" },
|
||
{ value: "red_lead", label: "Red Lead" },
|
||
{ value: "blue_lead", label: "Blue Lead" },
|
||
{ value: "manager", label: "Manager" },
|
||
{ value: "admin", label: "Admin" },
|
||
];
|
||
```
|
||
|
||
Update `roleBadgeColors` (line 26):
|
||
|
||
```typescript
|
||
const roleBadgeColors: Record<string, string> = {
|
||
admin: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||
red_tech: "bg-red-900/50 text-red-400 border-red-500/30",
|
||
blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||
red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||
blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
|
||
manager: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Add `manager` to the SSO default-role dropdown**
|
||
|
||
In `frontend/src/pages/SettingsPage.tsx`, find the role options array around line 1217 and add manager between blue_lead and red_tech (or any consistent position):
|
||
|
||
```typescript
|
||
{ value: "admin", label: "Aegis Admin", desc: "Full platform access including system settings" },
|
||
{ value: "red_lead", label: "Aegis Red Lead", desc: "Red team lead — manage tests, campaigns and templates" },
|
||
{ value: "blue_lead", label: "Aegis Blue Lead", desc: "Blue team lead — validate tests and manage coverage" },
|
||
{ value: "manager", label: "Aegis Manager", desc: "Approves campaigns and campaign changes" },
|
||
{ value: "red_tech", label: "Aegis Red Tech", desc: "Red team technician — execute tests" },
|
||
{ value: "blue_tech", label: "Aegis Blue Tech", desc: "Blue team technician — review detections" },
|
||
{ value: "viewer", label: "Aegis Viewer", desc: "Read-only access to dashboards and reports" },
|
||
```
|
||
|
||
- [ ] **Step 5: Verify TypeScript compiles**
|
||
|
||
Run (from `frontend/`): `npm run build`
|
||
Expected: build succeeds with zero errors.
|
||
|
||
- [ ] **Step 6: Manual verification in the browser**
|
||
|
||
As `admin`, go to Users, create a user with role "Manager," confirm the badge renders correctly. Open a campaign with some audit history (submit/approve/reject a test campaign) and confirm the Timeline section lists the actions in order with readable labels.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/CampaignAuditTimeline.tsx frontend/src/pages/CampaignDetailPage.tsx frontend/src/pages/UsersPage.tsx frontend/src/pages/SettingsPage.tsx
|
||
git commit -m "feat(campaigns): audit timeline UI and manager role in role pickers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Final end-to-end verification
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [ ] **Step 1: Run the full backend suite one more time**
|
||
|
||
Run (from `backend/`): `pytest tests/ -v`
|
||
Expected: 100% pass.
|
||
|
||
- [ ] **Step 2: Run the frontend build one more time**
|
||
|
||
Run (from `frontend/`): `npm run build`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 3: Full manual walkthrough in the browser**
|
||
|
||
Using the preview tools (or `npm run dev`):
|
||
1. As `red_lead`: create a campaign, add a test, submit for approval. Confirm you cannot see an Activate button and cannot edit start date anywhere.
|
||
2. As `manager`: see the campaign show up when filtering by "Pending Approval" on the Campaigns list, open it, approve it with a start date. Confirm it becomes `active` with that date shown.
|
||
3. As `red_lead`: on the now-active campaign, request removing the test with a justification. Confirm the test is still present.
|
||
4. As `manager`: approve the modification request. Confirm the test disappears from the campaign and the request shows "approved."
|
||
5. On the campaign detail page, check the Timeline section shows: created → submitted for approval → approved → modification requested → modification approved, in that order.
|
||
6. As `red_tech`: confirm you get no Submit/Approve/Request-Modification buttons anywhere (view-only).
|
||
|
||
- [ ] **Step 4: Report completion**
|
||
|
||
No commit for this task — it's verification only. If any step in the walkthrough fails, return to the relevant task above and fix before considering the feature done.
|
||
|
||
---
|