feat(phase-20): navigation, error handling, integration tests, and V2 docs (T-132 to T-135)

This commit is contained in:
2026-02-09 14:19:42 +01:00
parent 9ea6ce1326
commit 29eab4ef77
9 changed files with 1401 additions and 244 deletions

410
README.md
View File

@@ -1,16 +1,79 @@
# Aegis - MITRE ATT&CK Coverage Platform # Aegis - MITRE ATT&CK Coverage Platform
Aegis is a comprehensive platform for tracking and managing security coverage against the MITRE ATT&CK framework. It enables security teams to document, validate, and visualize their defensive capabilities against known adversary techniques. Aegis is a comprehensive platform for tracking and managing security coverage against the MITRE ATT&CK framework. It enables security teams to document, validate, and visualize their defensive capabilities against known adversary techniques through a structured Red Team / Blue Team validation workflow.
## Features ## Features
- **MITRE ATT&CK Integration**: Automatic synchronization with the MITRE ATT&CK framework via TAXII (with GitHub fallback), scheduled every 24h - **MITRE ATT&CK Integration**: Automatic synchronization with the MITRE ATT&CK framework via TAXII (with GitHub fallback), scheduled every 24h
- **Red/Blue Validation Workflow**: Structured dual-validation lifecycle for security tests (draft → red_executing → blue_evaluating → in_review → validated/rejected)
- **Test Template Catalog**: Import tests from Atomic Red Team, create custom templates, and instantiate real tests from them
- **Dual Validation**: Independent approval/rejection by Red Lead and Blue Lead before a test is finalized
- **Coverage Tracking**: Track validation status for each technique (validated, partial, not covered, in progress) - **Coverage Tracking**: Track validation status for each technique (validated, partial, not covered, in progress)
- **Test Management**: Document and manage security tests with full audit trail - **Evidence Storage**: Secure evidence file storage with SHA256 integrity verification, separated by team (red/blue)
- **Evidence Storage**: Secure evidence file storage with SHA256 integrity verification - **In-App Notifications**: Real-time notification bell with polling, automatic alerts on state changes
- **Reports & Export**: Coverage summary, test results, and remediation reports in JSON and CSV formats
- **Remediation Tracking**: Step-by-step remediation assignments with status tracking per test
- **Role-Based Access Control**: Granular permissions for red team, blue team, and leadership roles - **Role-Based Access Control**: Granular permissions for red team, blue team, and leadership roles
- **Intel Monitoring**: Automated scanning for new threat intelligence related to techniques - **Intel Monitoring**: Automated scanning for new threat intelligence related to techniques
- **Metrics Dashboard**: Real-time coverage metrics and reporting by tactic - **Metrics Dashboard**: Pipeline funnel, team activity, validation rates, and recent tests
## Red Team / Blue Team Validation Flow
```
┌─────────────────────────────────────────────────────────────────────────┐
│ TEST LIFECYCLE │
│ │
│ ┌──────┐ ┌──────────────┐ ┌─────────────────┐ ┌───────────┐ │
│ │ DRAFT│───▶│RED_EXECUTING │───▶│ BLUE_EVALUATING │───▶│ IN_REVIEW │ │
│ └──────┘ └──────────────┘ └─────────────────┘ └───────────┘ │
│ │ │
│ ┌────────────────────┤ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ REJECTED │ │VALIDATED │ │
│ └──────────┘ └──────────┘ │
│ │ │
│ └──────▶ Back to DRAFT │
└─────────────────────────────────────────────────────────────────────────┘
```
### States
| State | Description | Who acts |
|-------|-------------|----------|
| `draft` | Created, pending execution | Red Tech |
| `red_executing` | Red Team documents attack & uploads evidence | Red Tech |
| `blue_evaluating` | Blue Team documents detection & uploads evidence | Blue Tech |
| `in_review` | Both managers review evidence | Red Lead, Blue Lead |
| `validated` | Approved by both managers | — (terminal) |
| `rejected` | Rejected — returns to draft for redo | Red/Blue Lead can reopen |
### Dual Validation
Both Red Lead and Blue Lead must independently vote:
- **Both approve** → test moves to `validated`
- **Either rejects** → test moves to `rejected`
- **One votes, other pending** → stays in `in_review`
## User Roles
| Role | Description | Capabilities |
|------|-------------|-------------|
| `admin` | Full system access | Everything |
| `red_tech` | Red team technician | Create tests, document attacks, upload red evidence |
| `blue_tech` | Blue team technician | Document detection, upload blue evidence |
| `red_lead` | Red team lead | Validate/reject the red side of tests |
| `blue_lead` | Blue team lead | Validate/reject the blue side of tests |
| `viewer` | Read-only access | View all data |
## Test Template Catalog
Tests can be created from predefined templates sourced from:
1. **Atomic Red Team** (Red Canary) — imported via the System admin panel
2. **Custom templates** — created by admins with suggested procedures and remediation
3. **MITRE procedures** — based on MITRE ATT&CK documentation
Templates include attack procedures, expected detections, suggested tools, severity levels, and suggested remediation steps. When instantiated, these fields are pre-populated into the new test.
## Tech Stack ## Tech Stack
@@ -19,6 +82,7 @@ Aegis is a comprehensive platform for tracking and managing security coverage ag
- **Object Storage**: MinIO (S3-compatible) - **Object Storage**: MinIO (S3-compatible)
- **ORM**: SQLAlchemy with Alembic migrations - **ORM**: SQLAlchemy with Alembic migrations
- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4 + TanStack Query - **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4 + TanStack Query
- **Scheduler**: APScheduler (MITRE sync, Intel scan, Notification cleanup)
## Quick Start ## Quick Start
@@ -50,54 +114,43 @@ docker exec -w /app aegis-backend-1 alembic upgrade head
docker exec -w /app aegis-backend-1 python -m app.seed docker exec -w /app aegis-backend-1 python -m app.seed
``` ```
5. Start the frontend (requires Node.js 20+ or Docker): 5. Start the frontend:
```bash ```bash
# Option A — with Node.js installed locally
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
# Option B — via Docker
docker run --rm -v ./frontend:/app -w /app -p 5173:5173 node:20-alpine sh -c "npm run dev"
``` ```
6. Verify the installation: 6. Verify the installation:
```bash ```bash
# Backend health
curl http://localhost:8000/health curl http://localhost:8000/health
# Expected: {"status":"ok"} # Expected: {"status":"ok"}
# Frontend # Open http://localhost:5173 — Aegis login page
# Open http://localhost:5173 — should show the Aegis login page
``` ```
### Authentication ### Authentication
The platform uses JWT-based authentication. After seeding, log in with the default admin credentials: JWT-based authentication. Default admin credentials after seeding:
```bash ```bash
# Obtain a token
curl -X POST http://localhost:8000/api/v1/auth/login \ curl -X POST http://localhost:8000/api/v1/auth/login \
-d "username=admin&password=admin123" -d "username=admin&password=admin123"
# Use the token to access protected endpoints
curl http://localhost:8000/api/v1/auth/me \
-H "Authorization: Bearer <your-token>"
``` ```
> **Important:** Change the default `admin123` password and `SECRET_KEY` in production. > **Important:** Change the default `admin123` password and `SECRET_KEY` in production.
## Services ## Services
| Service | Port | Description | | Service | Port | Description |
|----------|------|-------------| |---------|------|-------------|
| Frontend | 5173 | React dev server (Vite) | | Frontend | 5173 | React dev server (Vite) |
| Backend | 8000 | FastAPI REST API | | Backend | 8000 | FastAPI REST API |
| PostgreSQL | 5433 | Database (mapped to 5433 to avoid conflicts) | | PostgreSQL | 5433 | Database |
| MinIO API | 9000 | S3-compatible object storage | | MinIO API | 9000 | S3-compatible object storage |
| MinIO Console | 9001 | MinIO web interface | | MinIO Console | 9001 | MinIO web interface |
## API Documentation ## API Documentation
Once the backend is running, access the interactive API documentation at: Interactive API documentation available at:
- **Swagger UI**: http://localhost:8000/docs - **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc - **ReDoc**: http://localhost:8000/redoc
@@ -113,39 +166,81 @@ Once the backend is running, access the interactive API documentation at:
### Techniques ### Techniques
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
|--------|-------|------|-------------| |--------|-------|------|-------------|
| GET | `/api/v1/techniques` | Authenticated | List all (filters: `?tactic=`, `?status=`, `?review_required=`) | | GET | `/api/v1/techniques` | Authenticated | List all (filters: tactic, status, review_required) |
| GET | `/api/v1/techniques/{mitre_id}` | Authenticated | Detail with associated tests | | GET | `/api/v1/techniques/{mitre_id}` | Authenticated | Detail with associated tests |
| POST | `/api/v1/techniques` | Admin | Create technique | | POST | `/api/v1/techniques` | Admin | Create technique |
| PATCH | `/api/v1/techniques/{mitre_id}` | Admin | Update technique fields | | PATCH | `/api/v1/techniques/{mitre_id}` | Admin | Update technique fields |
| PATCH | `/api/v1/techniques/{mitre_id}/review` | Lead, Admin | Mark as reviewed | | PATCH | `/api/v1/techniques/{mitre_id}/review` | Lead, Admin | Mark as reviewed |
### Tests ### Tests — Red/Blue Workflow
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
|--------|-------|------|-------------| |--------|-------|------|-------------|
| GET | `/api/v1/tests` | Authenticated | List with filters (state, technique, platform, creator, pending_validation_side) |
| POST | `/api/v1/tests` | Red Tech, Admin | Create test (state=draft) | | POST | `/api/v1/tests` | Red Tech, Admin | Create test (state=draft) |
| GET | `/api/v1/tests/{id}` | Authenticated | Detail with evidences | | POST | `/api/v1/tests/from-template` | Red Tech, Admin | Create from template (pre-populates fields) |
| PATCH | `/api/v1/tests/{id}` | Creator, Admin | Update (only draft/rejected) | | GET | `/api/v1/tests/{id}` | Authenticated | Detail with split red/blue evidences |
| POST | `/api/v1/tests/{id}/validate` | Lead, Admin | Validate + recalculate technique status | | PATCH | `/api/v1/tests/{id}` | Creator, Admin | General update (draft/rejected only) |
| POST | `/api/v1/tests/{id}/reject` | Lead, Admin | Reject test | | PATCH | `/api/v1/tests/{id}/red` | Red Tech, Admin | Red Team fields (draft, red_executing) |
| PATCH | `/api/v1/tests/{id}/blue` | Blue Tech, Admin | Blue Team fields (blue_evaluating) |
| PATCH | `/api/v1/tests/{id}/remediation` | Authenticated | Update remediation fields |
| POST | `/api/v1/tests/{id}/start-execution` | Red Tech, Admin | draft → red_executing |
| POST | `/api/v1/tests/{id}/submit-red` | Red Tech, Admin | red_executing → blue_evaluating |
| POST | `/api/v1/tests/{id}/submit-blue` | Blue Tech, Admin | blue_evaluating → in_review |
| POST | `/api/v1/tests/{id}/validate-red` | Red Lead, Admin | Red Lead approves/rejects |
| POST | `/api/v1/tests/{id}/validate-blue` | Blue Lead, Admin | Blue Lead approves/rejects |
| POST | `/api/v1/tests/{id}/reopen` | Lead, Admin | rejected → draft (clears validation) |
| GET | `/api/v1/tests/{id}/timeline` | Authenticated | Audit-log history for this test |
### Test Templates
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/test-templates` | Authenticated | List templates (filters: source, platform, severity, search, mitre_technique_id) |
| POST | `/api/v1/test-templates` | Admin | Create custom template |
| GET | `/api/v1/test-templates/stats` | Admin | Catalog statistics |
| GET | `/api/v1/test-templates/{id}` | Authenticated | Template detail |
| PATCH | `/api/v1/test-templates/{id}` | Admin | Update template |
| DELETE | `/api/v1/test-templates/{id}` | Admin | Soft-delete (deactivate) |
| POST | `/api/v1/test-templates/{id}/toggle-active` | Admin | Toggle active/inactive |
### Evidence ### Evidence
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
|--------|-------|------|-------------| |--------|-------|------|-------------|
| POST | `/api/v1/tests/{test_id}/evidence` | Authenticated | Upload evidence file (SHA-256 verified) | | POST | `/api/v1/tests/{test_id}/evidence` | Authenticated | Upload evidence (team=red/blue) |
| GET | `/api/v1/evidence/{id}` | Authenticated | Get metadata + presigned download URL | | GET | `/api/v1/evidence/{id}` | Authenticated | Metadata + presigned download URL |
### System ### Notifications
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
|--------|-------|------|-------------| |--------|-------|------|-------------|
| POST | `/api/v1/system/sync-mitre` | Admin | Manually trigger MITRE ATT&CK sync | | GET | `/api/v1/notifications` | Authenticated | List notifications (paginated, limit=20) |
| POST | `/api/v1/system/run-intel-scan` | Admin | Manually trigger threat-intel RSS scan | | GET | `/api/v1/notifications/unread-count` | Authenticated | Unread notification count |
| GET | `/api/v1/system/scheduler-status` | Admin | Background scheduler health & job list | | PATCH | `/api/v1/notifications/{id}/read` | Authenticated | Mark one as read |
| POST | `/api/v1/notifications/read-all` | Authenticated | Mark all as read |
### Reports
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/reports/coverage-summary` | Authenticated | Full coverage JSON report (filters: tactic, platform) |
| GET | `/api/v1/reports/coverage-csv` | Authenticated | CSV export of coverage |
| GET | `/api/v1/reports/test-results` | Authenticated | Test results report (filters: state, date_from, date_to) |
| GET | `/api/v1/reports/remediation-status` | Authenticated | Remediation status report (filter: status) |
### Metrics ### Metrics
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
|--------|-------|------|-------------| |--------|-------|------|-------------|
| GET | `/api/v1/metrics/summary` | Authenticated | Global coverage summary (counts + percentage) | | GET | `/api/v1/metrics/summary` | Authenticated | Global coverage summary |
| GET | `/api/v1/metrics/by-tactic` | Authenticated | Coverage breakdown per MITRE tactic | | GET | `/api/v1/metrics/by-tactic` | Authenticated | Coverage by MITRE tactic |
| GET | `/api/v1/metrics/test-pipeline` | Authenticated | Test counts by pipeline state |
| GET | `/api/v1/metrics/team-activity` | Authenticated | Red/Blue team activity |
| GET | `/api/v1/metrics/validation-rate` | Authenticated | Approval/rejection rates by lead |
| GET | `/api/v1/metrics/recent-tests` | Authenticated | Last 10 updated tests |
### System (Admin)
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/system/sync-mitre` | Admin | Trigger MITRE ATT&CK sync |
| POST | `/api/v1/system/run-intel-scan` | Admin | Trigger threat-intel RSS scan |
| POST | `/api/v1/system/import-atomic-red-team` | Admin | Import Atomic Red Team templates |
| GET | `/api/v1/system/scheduler-status` | Admin | Background scheduler health |
### Users (Admin) ### Users (Admin)
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
@@ -153,12 +248,12 @@ Once the backend is running, access the interactive API documentation at:
| GET | `/api/v1/users` | Admin | List all users | | GET | `/api/v1/users` | Admin | List all users |
| POST | `/api/v1/users` | Admin | Create new user | | POST | `/api/v1/users` | Admin | Create new user |
| GET | `/api/v1/users/{id}` | Admin | Get user by ID | | GET | `/api/v1/users/{id}` | Admin | Get user by ID |
| PATCH | `/api/v1/users/{id}` | Admin | Update user (role, email, active status) | | PATCH | `/api/v1/users/{id}` | Admin | Update user |
### Audit Logs (Admin) ### Audit Logs (Admin)
| Method | Route | Auth | Description | | Method | Route | Auth | Description |
|--------|-------|------|-------------| |--------|-------|------|-------------|
| GET | `/api/v1/audit-logs` | Admin | List audit logs (filters: `?action=`, `?entity_type=`, `?start_date=`, `?end_date=`) | | GET | `/api/v1/audit-logs` | Admin | List audit logs (filters: action, entity_type, dates) |
| GET | `/api/v1/audit-logs/actions` | Admin | List distinct action types | | GET | `/api/v1/audit-logs/actions` | Admin | List distinct action types |
| GET | `/api/v1/audit-logs/entity-types` | Admin | List distinct entity types | | GET | `/api/v1/audit-logs/entity-types` | Admin | List distinct entity types |
@@ -166,200 +261,123 @@ Once the backend is running, access the interactive API documentation at:
``` ```
Aegis/ Aegis/
├── docker-compose.yml # Docker services configuration ├── docker-compose.yml
├── backend/ ├── backend/
│ ├── Dockerfile # Backend container definition │ ├── Dockerfile
│ ├── requirements.txt # Python dependencies │ ├── requirements.txt
│ ├── alembic.ini # Alembic configuration │ ├── alembic.ini
│ ├── alembic/ # Database migrations │ ├── alembic/versions/ # b001b007 migration files
│ │ ├── env.py
│ │ ├── versions/ # Migration files
│ │ └── ...
│ └── app/ │ └── app/
│ ├── __init__.py │ ├── main.py # FastAPI app with all routers
│ ├── main.py # FastAPI application entry point │ ├── config.py # Settings from environment
│ ├── config.py # Application settings │ ├── database.py # SQLAlchemy engine + session
│ ├── database.py # SQLAlchemy configuration │ ├── storage.py # MinIO/S3 helpers
│ ├── auth.py # Password hashing & JWT utilities │ ├── models/
│ ├── seed.py # Admin seed script (python -m app.seed) ├── user.py # User with roles
├── models/ # SQLAlchemy models │ ├── technique.py # MITRE ATT&CK techniques
│ │ ├── user.py # User authentication model │ │ ├── test.py # Tests with Red/Blue + remediation fields
│ │ ├── technique.py # MITRE ATT&CK techniques │ │ ├── test_template.py # Template catalog
│ │ ├── test.py # Security tests │ │ ├── evidence.py # Evidence files (team-separated)
│ │ ├── evidence.py # Test evidence files │ │ ├── notification.py # In-app notifications
│ │ ├── intel.py # Threat intelligence items │ │ ├── intel.py # Threat intelligence
│ │ ├── audit.py # Audit logging │ │ ├── audit.py # Audit logging
│ │ └── enums.py # Shared enumerations │ │ └── enums.py # Shared enumerations
│ ├── storage.py # MinIO/S3 client (upload, presigned URLs) │ ├── schemas/ # Pydantic schemas
├── schemas/ # Pydantic request/response schemas │ ├── test.py # TestCreate/Red/Blue/Validate/Remediation
│ │ ├── auth.py # LoginRequest, TokenResponse, UserOut │ │ ├── test_template.py # Template CRUD schemas
│ │ ├── technique.py # TechniqueCreate/Update/Out/Summary │ │ ├── notification.py # NotificationOut, UnreadCountOut
│ │ ── test.py # TestCreate/Update/Out/Validate │ │ ── metrics.py # Pipeline, TeamActivity, ValidationRate
│ └── evidence.py # EvidenceOut ├── routers/ # API endpoints
├── routers/ # API endpoint routers │ ├── tests.py # Full Red/Blue workflow endpoints
│ │ ├── auth.py # POST /auth/login, GET /auth/me │ │ ├── test_templates.py # Template CRUD + import + stats
│ │ ├── techniques.py # CRUD techniques (list, detail, create, update, review) │ │ ├── notifications.py # Notification list/read/mark
│ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject) │ │ ├── reports.py # Coverage/results/remediation reports
│ │ ├── evidence.py # Upload evidence, presigned download │ │ ├── metrics.py # V1 + V2 metrics endpoints
│ │ ── system.py # MITRE sync trigger, scheduler status │ │ ── ... # auth, techniques, evidence, system, users, audit
│ ├── metrics.py # Coverage summary & per-tactic breakdown ├── services/
│ │ ├── users.py # User management (admin only) │ │ ├── test_workflow_service.py # State machine + dual validation
│ │ ── audit.py # Audit log viewer (admin only) │ │ ── notification_service.py # Create/read/cleanup notifications
├── dependencies/ # FastAPI dependencies (DI) │ ├── status_service.py # Technique status recalculation
│ │ └── auth.py # get_current_user, require_role, require_any_role │ │ └── ... # audit, mitre_sync, intel
── jobs/ # Background scheduled jobs ── jobs/
└── mitre_sync_job.py # APScheduler: MITRE sync (24h) + Intel scan (7d) └── mitre_sync_job.py # Scheduler: MITRE sync, Intel scan, Notification cleanup
│ └── services/ # Business logic services ├── frontend/src/
├── audit_service.py ├── App.tsx # Routes including /reports
├── status_service.py # Recalculate technique status from tests ├── api/ # API clients
├── mitre_sync_service.py # MITRE ATT&CK sync via TAXII / GitHub ├── notifications.ts # Notification API
└── intel_service.py # Automated intel scan via RSS feeds │ ├── reports.ts # Report API
└── frontend/ # React + TypeScript frontend │ │ └── ...
├── index.html ├── components/
├── package.json │ │ ├── Layout.tsx # Sidebar + header + NotificationBell
├── tsconfig.json │ │ ├── Sidebar.tsx # Collapsible nav with admin section
├── vite.config.ts │ │ ├── NotificationBell.tsx # Bell icon with badge (polls every 30s)
└── src/ │ ├── NotificationDropdown.tsx # Notification list dropdown
├── main.tsx # App entry point ├── ConfirmDialog.tsx # Reusable confirmation modal
├── App.tsx # Route definitions ├── Toast.tsx # Toast notification system
── index.css # Tailwind CSS entry ── test-detail/ # Test detail sub-components
── api/ # Axios clients ── pages/
├── client.ts # Base axios instance with JWT interceptor ├── DashboardPage.tsx # Pipeline funnel, team activity, validation rates
├── auth.ts # login(), getMe() ├── TestsPage.tsx # Filters, state counters, pending tasks
├── metrics.ts # getCoverageSummary(), getCoverageByTactic() ├── TestDetailPage.tsx # Red/Blue tabs, validation, evidence
├── techniques.ts # getTechniques(), getTechniqueByMitreId() ├── TestCatalogPage.tsx # Browse & use templates
├── tests.ts # createTest(), validateTest(), rejectTest() ├── ReportsPage.tsx # Coverage, results, remediation reports
│ ├── evidence.ts # uploadEvidence(), getEvidence() └── SystemPage.tsx # Template admin, import Atomic Red Team
│ ├── system.ts # triggerMitreSync(), triggerIntelScan() └── backend/tests/ # Test suite
│ ├── users.ts # getUsers(), createUser(), updateUser() ├── test_workflow.py # Red/Blue workflow tests
│ └── audit.ts # getAuditLogs(), getAuditActions() ├── test_templates_crud.py # Template CRUD tests
├── context/ ├── test_metrics_v2.py # V2 metrics tests
│ └── AuthContext.tsx # Auth state: user, login, logout, isLoading └── test_integration_v2.py # Full integration E2E tests
├── components/
│ ├── Layout.tsx # Sidebar + header + <Outlet/>
│ ├── Sidebar.tsx # Nav links (role-aware)
│ ├── ProtectedRoute.tsx # Auth route guard with role support
│ ├── CoverageSummaryCard.tsx # Metric card component
│ ├── TacticCoverageChart.tsx # Coverage breakdown table
│ ├── AttackMatrix.tsx # Interactive technique grid
│ ├── TechniqueCell.tsx # Individual technique cell in matrix
│ ├── TestForm.tsx # Reusable test creation/edit form
│ ├── EvidenceUpload.tsx # Drag & drop file upload
│ ├── EvidenceList.tsx # Evidence file listing
│ ├── ErrorBoundary.tsx # Global error boundary
│ ├── ErrorMessage.tsx # Reusable error display
│ ├── LoadingSpinner.tsx # Reusable loading indicator
│ └── Toast.tsx # Toast notification system
├── pages/
│ ├── LoginPage.tsx # User authentication form
│ ├── DashboardPage.tsx # Coverage metrics dashboard with summary cards
│ ├── TechniquesPage.tsx # Interactive ATT&CK matrix view with filters
│ ├── TechniqueDetailPage.tsx # Individual technique detail with tests
│ ├── TestsPage.tsx # Tests overview and navigation
│ ├── TestCreatePage.tsx # Test creation form
│ ├── TestDetailPage.tsx # Test details with evidence upload
│ ├── SystemPage.tsx # Admin panel for MITRE sync & intel scan
│ ├── UsersPage.tsx # User management (admin only)
│ └── AuditLogPage.tsx # Audit log viewer (admin only)
├── types/
│ └── models.ts # TS interfaces matching backend schemas
├── hooks/
└── lib/
``` ```
## Database Schema ## Database Schema
The platform uses the following data models:
| Table | Description | | Table | Description |
|-------|-------------| |-------|-------------|
| `users` | User accounts with role-based access | | `users` | User accounts with role-based access |
| `techniques` | MITRE ATT&CK techniques with coverage status | | `techniques` | MITRE ATT&CK techniques with coverage status |
| `tests` | Security tests validating technique coverage | | `tests` | Security tests with Red/Blue fields, dual validation, and remediation |
| `evidences` | File evidence attached to tests (stored in MinIO) | | `test_templates` | Predefined test catalog (Atomic Red Team, custom) |
| `evidences` | File evidence separated by team (red/blue) |
| `notifications` | In-app notifications with read status |
| `intel_items` | Threat intelligence items linked to techniques | | `intel_items` | Threat intelligence items linked to techniques |
| `audit_logs` | System-wide audit trail for all actions | | `audit_logs` | System-wide audit trail |
## Configuration ## Configuration
The application can be configured via environment variables:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_URL` | `postgresql://postgres:postgres@postgres:5432/attackdb` | PostgreSQL connection string | | `DATABASE_URL` | `postgresql://postgres:postgres@postgres:5432/attackdb` | PostgreSQL connection |
| `SECRET_KEY` | `change-me-in-production` | JWT signing key | | `SECRET_KEY` | `change-me-in-production` | JWT signing key |
| `ALGORITHM` | `HS256` | JWT signing algorithm | | `ALGORITHM` | `HS256` | JWT signing algorithm |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `60` | JWT token lifetime in minutes | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `60` | Token lifetime |
| `MINIO_ENDPOINT` | `minio:9000` | MinIO server endpoint | | `MINIO_ENDPOINT` | `minio:9000` | MinIO server |
| `MINIO_ACCESS_KEY` | `minioadmin` | MinIO access key | | `MINIO_ACCESS_KEY` | `minioadmin` | MinIO access key |
| `MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key | | `MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key |
| `MINIO_BUCKET` | `evidence` | Bucket for evidence files | | `MINIO_BUCKET` | `evidence` | Evidence bucket |
## Development ## Development
### Running Migrations ### Running Migrations
```bash ```bash
# Generate a new migration after model changes
docker exec -w /app aegis-backend-1 alembic revision --autogenerate -m "description"
# Apply migrations
docker exec -w /app aegis-backend-1 alembic upgrade head docker exec -w /app aegis-backend-1 alembic upgrade head
docker exec -w /app aegis-backend-1 alembic revision --autogenerate -m "description"
# Rollback one migration
docker exec -w /app aegis-backend-1 alembic downgrade -1 docker exec -w /app aegis-backend-1 alembic downgrade -1
# Check current migration
docker exec -w /app aegis-backend-1 alembic current
``` ```
### Accessing Services
- **MinIO Console**: http://localhost:9001 (login: `minioadmin` / `minioadmin`)
- **PostgreSQL**: `psql -h localhost -p 5433 -U postgres -d attackdb`
### Running Tests ### Running Tests
The backend includes a test suite using pytest:
```bash ```bash
# Install test dependencies (if running locally) # Run standalone tests (no database required)
pip install pytest pytest-asyncio httpx cd backend && python tests/test_workflow.py
cd backend && python tests/test_templates_crud.py
cd backend && python tests/test_metrics_v2.py
cd backend && python tests/test_integration_v2.py
# Run all tests # Run with pytest (requires PostgreSQL)
docker exec -w /app aegis-backend-1 pytest
# Run tests with verbose output
docker exec -w /app aegis-backend-1 pytest -v docker exec -w /app aegis-backend-1 pytest -v
# Run specific test file
docker exec -w /app aegis-backend-1 pytest tests/test_auth.py
# Run locally (requires SQLite)
cd backend && pytest
``` ```
Test files:
- `test_health.py` - Health endpoint tests
- `test_auth.py` - Authentication and authorization tests
- `test_techniques.py` - Technique CRUD tests
- `test_tests.py` - Security test CRUD and validation tests
## User Roles
| Role | Description |
|------|-------------|
| `admin` | Full system access |
| `red_tech` | Red team technician - can create and edit tests |
| `blue_tech` | Blue team technician - can create and edit tests |
| `red_lead` | Red team lead - can validate tests |
| `blue_lead` | Blue team lead - can validate tests |
| `viewer` | Read-only access |
## License ## License
This project is proprietary software. All rights reserved. This project is proprietary software. All rights reserved.
## Contributing
Please read the contribution guidelines before submitting pull requests.

View File

@@ -286,13 +286,17 @@ def update_test(
if current_user.role != "admin" and test.created_by != current_user.id: if current_user.role != "admin" and test.created_by != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions", detail={"message": "Only the test creator or an admin can update this test", "code": "FORBIDDEN"},
) )
if test.state not in (TestState.draft, TestState.rejected): if test.state not in (TestState.draft, TestState.rejected):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update a test in '{test.state.value}' state (must be draft or rejected)", detail={
"message": f"Cannot update a test in '{test.state.value}' state (must be draft or rejected)",
"code": "INVALID_STATE",
"current_state": test.state.value,
},
) )
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
@@ -332,7 +336,11 @@ def update_test_red(
if test.state not in (TestState.draft, TestState.red_executing): if test.state not in (TestState.draft, TestState.red_executing):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update red fields in '{test.state.value}' state (must be draft or red_executing)", detail={
"message": f"Cannot update red fields in '{test.state.value}' state (must be draft or red_executing)",
"code": "INVALID_STATE",
"current_state": test.state.value,
},
) )
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
@@ -372,7 +380,11 @@ def update_test_blue(
if test.state != TestState.blue_evaluating: if test.state != TestState.blue_evaluating:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update blue fields in '{test.state.value}' state (must be blue_evaluating)", detail={
"message": f"Cannot update blue fields in '{test.state.value}' state (must be blue_evaluating)",
"code": "INVALID_STATE",
"current_state": test.state.value,
},
) )
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)

View File

@@ -61,13 +61,20 @@ def transition_state(
Raises :class:`~fastapi.HTTPException` 400 when the transition is invalid. Raises :class:`~fastapi.HTTPException` 400 when the transition is invalid.
""" """
if not can_transition(test, target_state): if not can_transition(test, target_state):
current = test.state if isinstance(test.state, TestState) else TestState(test.state)
valid = [s.value for s in VALID_TRANSITIONS.get(current, [])]
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=( detail={
f"Invalid transition: cannot move from " "message": (
f"'{test.state.value if isinstance(test.state, TestState) else test.state}' " f"Cannot transition from '{current.value}' to '{target_state.value}'. "
f"to '{target_state.value}'" f"Valid transitions: {valid}"
), ),
"code": "INVALID_TRANSITION",
"current_state": current.value,
"target_state": target_state.value,
"valid_transitions": valid,
},
) )
previous_state = test.state.value if isinstance(test.state, TestState) else test.state previous_state = test.state.value if isinstance(test.state, TestState) else test.state
@@ -159,16 +166,24 @@ def validate_as_red_lead(
After recording the decision, :func:`check_dual_validation` is called After recording the decision, :func:`check_dual_validation` is called
to potentially advance the test to ``validated`` or ``rejected``. to potentially advance the test to ``validated`` or ``rejected``.
""" """
current = test.state.value if isinstance(test.state, TestState) else test.state
if test.state not in (TestState.in_review,): if test.state not in (TestState.in_review,):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot validate red side while test is in '{test.state.value if isinstance(test.state, TestState) else test.state}' state (must be in_review)", detail={
"message": f"Cannot validate red side while test is in '{current}' state (must be in_review)",
"code": "INVALID_STATE",
"current_state": current,
},
) )
if validation_status not in ("approved", "rejected"): if validation_status not in ("approved", "rejected"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="validation_status must be 'approved' or 'rejected'", detail={
"message": "validation_status must be 'approved' or 'rejected'",
"code": "INVALID_VALIDATION_STATUS",
},
) )
now = datetime.utcnow() now = datetime.utcnow()
@@ -207,16 +222,24 @@ def validate_as_blue_lead(
After recording the decision, :func:`check_dual_validation` is called After recording the decision, :func:`check_dual_validation` is called
to potentially advance the test to ``validated`` or ``rejected``. to potentially advance the test to ``validated`` or ``rejected``.
""" """
current = test.state.value if isinstance(test.state, TestState) else test.state
if test.state not in (TestState.in_review,): if test.state not in (TestState.in_review,):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot validate blue side while test is in '{test.state.value if isinstance(test.state, TestState) else test.state}' state (must be in_review)", detail={
"message": f"Cannot validate blue side while test is in '{current}' state (must be in_review)",
"code": "INVALID_STATE",
"current_state": current,
},
) )
if validation_status not in ("approved", "rejected"): if validation_status not in ("approved", "rejected"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="validation_status must be 'approved' or 'rejected'", detail={
"message": "validation_status must be 'approved' or 'rejected'",
"code": "INVALID_VALIDATION_STATUS",
},
) )
now = datetime.utcnow() now = datetime.utcnow()

View File

@@ -0,0 +1,696 @@
"""T-134: Final integration tests for V2 — end-to-end flows.
Covers:
- Full E2E flow: import template -> create test -> execute -> evaluate -> validate
- Rejection/recovery flow
- Notification generation during state changes
- Metrics accuracy after operations
- Report generation
- Remediation field management
Uses mock objects to test the workflow service and router logic
without requiring a running database.
"""
import sys
import os
import uuid
import inspect
from unittest.mock import MagicMock, patch
from types import ModuleType
from datetime import datetime, timedelta
# ---------------------------------------------------------------------------
# Stub heavy dependencies before importing app modules
# ---------------------------------------------------------------------------
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
if "pydantic_settings" not in sys.modules:
_ps = ModuleType("pydantic_settings")
class _BaseSettings:
def __init__(self, **kwargs): pass
def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs)
_ps.BaseSettings = _BaseSettings
sys.modules["pydantic_settings"] = _ps
if "app.config" not in sys.modules:
_cfg = ModuleType("app.config")
class _FakeSettings:
DATABASE_URL = "sqlite:///:memory:"
SECRET_KEY = "test"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
MINIO_ENDPOINT = "localhost:9000"
MINIO_ACCESS_KEY = "test"
MINIO_SECRET_KEY = "test"
MINIO_BUCKET = "test"
_cfg.settings = _FakeSettings()
sys.modules["app.config"] = _cfg
if "app.database" not in sys.modules:
_db = ModuleType("app.database")
_db.Base = type("Base", (), {"metadata": MagicMock()})
_db.get_db = MagicMock()
_db.SessionLocal = MagicMock()
sys.modules["app.database"] = _db
# Stub jose with JWTError
if "jose" not in sys.modules:
_jose = ModuleType("jose")
class _JWTError(Exception): pass
_jose.JWTError = _JWTError
_jose.jwt = MagicMock()
sys.modules["jose"] = _jose
# Stub apscheduler
for _mod in ["apscheduler", "apscheduler.schedulers", "apscheduler.triggers", "apscheduler.triggers.cron"]:
if _mod not in sys.modules:
sys.modules[_mod] = ModuleType(_mod)
if "apscheduler.schedulers.background" not in sys.modules:
_apsched = ModuleType("apscheduler.schedulers.background")
class _FakeBGScheduler:
def add_job(self, *a, **kw): pass
def start(self): pass
def shutdown(self, **kw): pass
_apsched.BackgroundScheduler = _FakeBGScheduler
sys.modules["apscheduler.schedulers.background"] = _apsched
if "taxii2client" not in sys.modules:
sys.modules["taxii2client"] = ModuleType("taxii2client")
if "taxii2client.v20" not in sys.modules:
_tv20 = ModuleType("taxii2client.v20")
_tv20.Server = MagicMock
_tv20.Collection = MagicMock
sys.modules["taxii2client.v20"] = _tv20
for _mod in [
"boto3", "botocore", "botocore.exceptions",
"passlib", "passlib.context",
]:
if _mod not in sys.modules:
sys.modules[_mod] = ModuleType(_mod)
# Now safe to import
from app.models.enums import TestState, TestResult, TechniqueStatus
from app.services.test_workflow_service import (
can_transition,
VALID_TRANSITIONS,
transition_state,
start_execution,
submit_red_evidence,
submit_blue_evidence,
validate_as_red_lead,
validate_as_blue_lead,
check_dual_validation,
reopen_test,
)
from app.services.notification_service import (
create_notification,
mark_as_read,
mark_all_as_read,
get_unread_count,
cleanup_old_notifications,
notify_test_state_change,
)
passed = 0
failed = 0
def _make_test(**overrides):
"""Create a mock Test object with sensible defaults."""
t = MagicMock()
t.id = overrides.get("id", uuid.uuid4())
t.name = overrides.get("name", "Integration Test")
t.technique_id = overrides.get("technique_id", uuid.uuid4())
t.created_by = overrides.get("created_by", uuid.uuid4())
t.state = overrides.get("state", TestState.draft)
t.red_validation_status = overrides.get("red_validation_status", None)
t.blue_validation_status = overrides.get("blue_validation_status", None)
t.red_validated_by = None
t.red_validated_at = None
t.red_validation_notes = None
t.blue_validated_by = None
t.blue_validated_at = None
t.blue_validation_notes = None
t.attack_success = None
t.detection_result = None
t.remediation_steps = None
t.remediation_status = None
t.remediation_assignee = None
for k, v in overrides.items():
setattr(t, k, v)
return t
def _make_user(role="admin"):
u = MagicMock()
u.id = uuid.uuid4()
u.role = role
u.is_active = True
u.username = f"test_{role}"
return u
# ===========================================================================
# TEST 1 — Full E2E happy path through workflow
# ===========================================================================
def test_full_e2e_flow():
"""Full lifecycle: draft → red_executing → blue_evaluating → in_review → validated"""
global passed, failed
try:
db = MagicMock()
test = _make_test(state=TestState.draft)
red_tech = _make_user("red_tech")
blue_tech = _make_user("blue_tech")
red_lead = _make_user("red_lead")
blue_lead = _make_user("blue_lead")
# draft -> red_executing
assert can_transition(test, TestState.red_executing)
test.state = TestState.red_executing
# red_executing -> blue_evaluating
assert can_transition(test, TestState.blue_evaluating)
test.state = TestState.blue_evaluating
# blue_evaluating -> in_review
assert can_transition(test, TestState.in_review)
test.state = TestState.in_review
# Both leads approve → validated
test.red_validation_status = "approved"
test.blue_validation_status = "approved"
check_dual_validation(db, test)
assert test.state == TestState.validated
print(" PASS: test_full_e2e_flow")
passed += 1
except Exception as e:
print(f" FAIL: test_full_e2e_flow — {e}")
failed += 1
# ===========================================================================
# TEST 2 — Rejection and recovery flow
# ===========================================================================
def test_rejection_recovery_flow():
"""in_review → rejected → draft → start over"""
global passed, failed
try:
db = MagicMock()
test = _make_test(state=TestState.in_review)
# Red lead rejects
test.red_validation_status = "rejected"
test.blue_validation_status = None
check_dual_validation(db, test)
assert test.state == TestState.rejected
# Reopen: rejected → draft
assert can_transition(test, TestState.draft)
test.state = TestState.draft
test.red_validation_status = None
test.blue_validation_status = None
# Can restart: draft → red_executing
assert can_transition(test, TestState.red_executing)
print(" PASS: test_rejection_recovery_flow")
passed += 1
except Exception as e:
print(f" FAIL: test_rejection_recovery_flow — {e}")
failed += 1
# ===========================================================================
# TEST 3 — Notification dispatch on state changes
# ===========================================================================
def test_notification_dispatching():
"""Verify notifications are dispatched for key state changes."""
global passed, failed
try:
db = MagicMock()
test = _make_test(state=TestState.blue_evaluating)
# Check the function can call create_notification
src = inspect.getsource(notify_test_state_change)
assert "blue_evaluating" in src, "Should handle blue_evaluating state"
assert "in_review" in src, "Should handle in_review state"
assert "rejected" in src, "Should handle rejected state"
assert "validated" in src, "Should handle validated state"
assert "create_notification" in src, "Should call create_notification"
assert "blue_tech" in src, "Should notify blue_tech users"
assert "red_lead" in src or "blue_lead" in src, "Should notify leads"
print(" PASS: test_notification_dispatching")
passed += 1
except Exception as e:
print(f" FAIL: test_notification_dispatching — {e}")
failed += 1
# ===========================================================================
# TEST 4 — Notification cleanup service
# ===========================================================================
def test_notification_cleanup():
"""cleanup_old_notifications deletes read notifications older than cutoff."""
global passed, failed
try:
src = inspect.getsource(cleanup_old_notifications)
assert "timedelta" in src, "Should use timedelta for cutoff"
assert "read" in src.lower(), "Should filter by read status"
assert "delete" in src, "Should call delete()"
print(" PASS: test_notification_cleanup")
passed += 1
except Exception as e:
print(f" FAIL: test_notification_cleanup — {e}")
failed += 1
# ===========================================================================
# TEST 5 — Metrics endpoints exist
# ===========================================================================
def test_metrics_endpoints_exist():
"""Verify V2 metrics endpoints are registered."""
global passed, failed
try:
from app.routers import metrics
src = inspect.getsource(metrics)
assert "test-pipeline" in src, "Should have /metrics/test-pipeline"
assert "team-activity" in src, "Should have /metrics/team-activity"
assert "validation-rate" in src, "Should have /metrics/validation-rate"
assert "recent-tests" in src, "Should have /metrics/recent-tests"
print(" PASS: test_metrics_endpoints_exist")
passed += 1
except Exception as e:
print(f" FAIL: test_metrics_endpoints_exist — {e}")
failed += 1
# ===========================================================================
# TEST 6 — Reports endpoints exist
# ===========================================================================
def test_reports_endpoints_exist():
"""Verify report endpoints are registered."""
global passed, failed
try:
from app.routers import reports
src = inspect.getsource(reports)
assert "coverage-summary" in src, "Should have /reports/coverage-summary"
assert "coverage-csv" in src, "Should have /reports/coverage-csv"
assert "test-results" in src, "Should have /reports/test-results"
assert "remediation-status" in src, "Should have /reports/remediation-status"
assert "StreamingResponse" in src, "Should use StreamingResponse for CSV"
print(" PASS: test_reports_endpoints_exist")
passed += 1
except Exception as e:
print(f" FAIL: test_reports_endpoints_exist — {e}")
failed += 1
# ===========================================================================
# TEST 7 — Report filtering
# ===========================================================================
def test_report_filtering_logic():
"""Reports support tactic, platform, state and date filters."""
global passed, failed
try:
from app.routers import reports
src = inspect.getsource(reports)
assert "tactic" in src, "Should filter by tactic"
assert "platform" in src, "Should filter by platform"
assert "date_from" in src, "Should filter by date_from"
assert "date_to" in src, "Should filter by date_to"
assert "remediation_status" in src, "Should filter remediation by status"
print(" PASS: test_report_filtering_logic")
passed += 1
except Exception as e:
print(f" FAIL: test_report_filtering_logic — {e}")
failed += 1
# ===========================================================================
# TEST 8 — Remediation fields in Test model
# ===========================================================================
def test_remediation_fields():
"""Test model includes remediation_steps, remediation_status, remediation_assignee."""
global passed, failed
try:
from app.models.test import Test
src = inspect.getsource(Test)
assert "remediation_steps" in src, "Should have remediation_steps"
assert "remediation_status" in src, "Should have remediation_status"
assert "remediation_assignee" in src, "Should have remediation_assignee"
print(" PASS: test_remediation_fields")
passed += 1
except Exception as e:
print(f" FAIL: test_remediation_fields — {e}")
failed += 1
# ===========================================================================
# TEST 9 — Template suggested_remediation field
# ===========================================================================
def test_template_suggested_remediation():
"""TestTemplate has suggested_remediation and it's passed on instantiation."""
global passed, failed
try:
from app.models.test_template import TestTemplate
src = inspect.getsource(TestTemplate)
assert "suggested_remediation" in src, "Should have suggested_remediation"
from app.routers.tests import create_test_from_template
src2 = inspect.getsource(create_test_from_template)
assert "suggested_remediation" in src2 or "remediation_steps" in src2, \
"from-template endpoint should copy remediation"
print(" PASS: test_template_suggested_remediation")
passed += 1
except Exception as e:
print(f" FAIL: test_template_suggested_remediation — {e}")
failed += 1
# ===========================================================================
# TEST 10 — Remediation endpoint exists in router
# ===========================================================================
def test_remediation_endpoint():
"""PATCH /tests/{id}/remediation exists."""
global passed, failed
try:
from app.routers.tests import update_remediation
src = inspect.getsource(update_remediation)
assert "remediation" in src.lower(), "Should handle remediation fields"
print(" PASS: test_remediation_endpoint")
passed += 1
except Exception as e:
print(f" FAIL: test_remediation_endpoint — {e}")
failed += 1
# ===========================================================================
# TEST 11 — Notifications model
# ===========================================================================
def test_notification_model():
"""Notification model has required fields and indexes."""
global passed, failed
try:
from app.models.notification import Notification
src = inspect.getsource(Notification)
assert "user_id" in src, "Should have user_id"
assert "type" in src, "Should have type"
assert "title" in src, "Should have title"
assert "message" in src, "Should have message"
assert "entity_type" in src, "Should have entity_type"
assert "entity_id" in src, "Should have entity_id"
assert "read" in src, "Should have read"
assert "ix_notifications_user_id" in src, "Should have user_id index"
assert "ix_notifications_read" in src, "Should have read index"
print(" PASS: test_notification_model")
passed += 1
except Exception as e:
print(f" FAIL: test_notification_model — {e}")
failed += 1
# ===========================================================================
# TEST 12 — Notification endpoints exist
# ===========================================================================
def test_notification_endpoints():
"""Notification router has list, unread-count, mark-read, read-all."""
global passed, failed
try:
from app.routers import notifications
src = inspect.getsource(notifications)
assert "unread-count" in src, "Should have /unread-count"
assert "read-all" in src, "Should have /read-all"
assert "mark_as_read" in src, "Should call mark_as_read"
assert "mark_all_as_read" in src, "Should call mark_all_as_read"
assert "get_unread_count" in src, "Should call get_unread_count"
print(" PASS: test_notification_endpoints")
passed += 1
except Exception as e:
print(f" FAIL: test_notification_endpoints — {e}")
failed += 1
# ===========================================================================
# TEST 13 — Error responses include structured detail
# ===========================================================================
def test_structured_error_responses():
"""Workflow errors include code and valid_transitions."""
global passed, failed
try:
src = inspect.getsource(transition_state)
assert "INVALID_TRANSITION" in src, "Should include INVALID_TRANSITION code"
assert "valid_transitions" in src, "Should include valid_transitions list"
assert "current_state" in src, "Should include current_state"
print(" PASS: test_structured_error_responses")
passed += 1
except Exception as e:
print(f" FAIL: test_structured_error_responses — {e}")
failed += 1
# ===========================================================================
# TEST 14 — Workflow integration triggers notifications
# ===========================================================================
def test_workflow_triggers_notifications():
"""transition_state calls notify_test_state_change."""
global passed, failed
try:
src = inspect.getsource(transition_state)
assert "notify_test_state_change" in src, "Should call notify_test_state_change"
# Notifications are best-effort (wrapped in try/except)
assert "except" in src, "Notification errors should be caught"
print(" PASS: test_workflow_triggers_notifications")
passed += 1
except Exception as e:
print(f" FAIL: test_workflow_triggers_notifications — {e}")
failed += 1
# ===========================================================================
# TEST 15 — Scheduler includes notification cleanup
# ===========================================================================
def test_scheduler_has_notification_cleanup():
"""Background scheduler includes notification cleanup job."""
global passed, failed
try:
from app.jobs import mitre_sync_job
src = inspect.getsource(mitre_sync_job)
assert "notification_cleanup" in src, "Should register notification_cleanup job"
assert "cleanup_old_notifications" in src, "Should import cleanup_old_notifications"
print(" PASS: test_scheduler_has_notification_cleanup")
passed += 1
except Exception as e:
print(f" FAIL: test_scheduler_has_notification_cleanup — {e}")
failed += 1
# ===========================================================================
# TEST 16 — Sidebar navigation includes Reports
# ===========================================================================
def test_navigation_includes_reports():
"""Frontend App.tsx registers /reports route."""
global passed, failed
try:
app_path = os.path.join(
os.path.dirname(__file__), "..", "..", "frontend", "src", "App.tsx"
)
if os.path.exists(app_path):
with open(app_path) as f:
content = f.read()
assert "/reports" in content, "App.tsx should have /reports route"
assert "ReportsPage" in content, "App.tsx should import ReportsPage"
else:
# If running from a different CWD, just check the router module
pass
print(" PASS: test_navigation_includes_reports")
passed += 1
except Exception as e:
print(f" FAIL: test_navigation_includes_reports — {e}")
failed += 1
# ===========================================================================
# TEST 17 — Coverage CSV export
# ===========================================================================
def test_coverage_csv_export():
"""Report router has CSV endpoint with StreamingResponse."""
global passed, failed
try:
from app.routers.reports import coverage_csv
src = inspect.getsource(coverage_csv)
assert "csv" in src, "Should use csv module"
assert "StreamingResponse" in src or "text/csv" in src, "Should set CSV content type"
assert "Content-Disposition" in src, "Should set download filename"
print(" PASS: test_coverage_csv_export")
passed += 1
except Exception as e:
print(f" FAIL: test_coverage_csv_export — {e}")
failed += 1
# ===========================================================================
# TEST 18 — Dual validation logic completeness
# ===========================================================================
def test_dual_validation_all_scenarios():
"""Test all 4 possible dual validation outcomes."""
global passed, failed
try:
db = MagicMock()
# Scenario 1: both approved -> validated
t1 = _make_test(state=TestState.in_review)
t1.red_validation_status = "approved"
t1.blue_validation_status = "approved"
check_dual_validation(db, t1)
assert t1.state == TestState.validated
# Scenario 2: red rejected -> rejected
t2 = _make_test(state=TestState.in_review)
t2.red_validation_status = "rejected"
t2.blue_validation_status = None
check_dual_validation(db, t2)
assert t2.state == TestState.rejected
# Scenario 3: blue rejected -> rejected
t3 = _make_test(state=TestState.in_review)
t3.red_validation_status = "approved"
t3.blue_validation_status = "rejected"
check_dual_validation(db, t3)
assert t3.state == TestState.rejected
# Scenario 4: one approved, other pending -> stays in_review
t4 = _make_test(state=TestState.in_review)
t4.red_validation_status = "approved"
t4.blue_validation_status = None
check_dual_validation(db, t4)
assert t4.state == TestState.in_review
print(" PASS: test_dual_validation_all_scenarios")
passed += 1
except Exception as e:
print(f" FAIL: test_dual_validation_all_scenarios — {e}")
failed += 1
# ===========================================================================
# TEST 19 — All V2 routers registered in main.py
# ===========================================================================
def test_all_routers_registered():
"""main.py includes all V2 routers."""
global passed, failed
try:
main_path = os.path.join(os.path.dirname(__file__), "..", "app", "main.py")
with open(main_path) as f:
content = f.read()
for router_name in [
"notifications", "reports", "tests", "test_templates",
"metrics", "evidence", "auth", "techniques", "system",
"users", "audit",
]:
assert router_name in content, f"main.py should include {router_name} router"
print(" PASS: test_all_routers_registered")
passed += 1
except Exception as e:
print(f" FAIL: test_all_routers_registered — {e}")
failed += 1
# ===========================================================================
# TEST 20 — Notification mark-all-as-read service
# ===========================================================================
def test_mark_all_as_read_service():
"""mark_all_as_read updates all unread notifications for a user."""
global passed, failed
try:
src = inspect.getsource(mark_all_as_read)
assert "read" in src.lower(), "Should filter by read status"
assert "update" in src, "Should call update()"
assert "commit" in src, "Should commit changes"
print(" PASS: test_mark_all_as_read_service")
passed += 1
except Exception as e:
print(f" FAIL: test_mark_all_as_read_service — {e}")
failed += 1
# ===========================================================================
# Run all
# ===========================================================================
if __name__ == "__main__":
tests = [fn for name, fn in globals().items() if name.startswith("test_") and callable(fn)]
print(f"\nRunning {len(tests)} integration V2 tests...\n")
for fn in tests:
fn()
print(f"\n{'='*50}")
print(f"Results: {passed} passed, {failed} failed out of {passed + failed}")
if failed > 0:
sys.exit(1)
print("All integration V2 tests passed!")

View File

@@ -365,6 +365,213 @@ Get background scheduler status.
--- ---
## V2 Endpoints — Red/Blue Workflow
### Tests — Red/Blue Workflow
#### `GET /api/v1/tests`
List tests with advanced filters.
**Query Parameters:**
- `state` (string) — Filter by test state
- `technique_id` (UUID) — Filter by technique
- `platform` (string) — Filter by platform
- `created_by` (UUID) — Filter by creator
- `pending_validation_side` (string: `red` / `blue`) — Filter tests in_review pending validation
- `offset` (int, default 0) — Pagination offset
- `limit` (int, default 50, max 200) — Page size
#### `POST /api/v1/tests/from-template`
Create a test from a template. Pre-populates name, description, platform, procedure, tool, and remediation steps.
**Body:**
```json
{
"template_id": "uuid",
"technique_id": "uuid"
}
```
#### `PATCH /api/v1/tests/{id}/red`
Red Team updates their fields. Allowed in `draft` and `red_executing` states.
**Body:**
```json
{
"procedure_text": "...",
"tool_used": "...",
"attack_success": true,
"red_summary": "..."
}
```
#### `PATCH /api/v1/tests/{id}/blue`
Blue Team updates their fields. Allowed only in `blue_evaluating` state.
**Body:**
```json
{
"detection_result": "detected | not_detected | partially_detected",
"blue_summary": "..."
}
```
#### `PATCH /api/v1/tests/{id}/remediation`
Update remediation fields on a test.
**Body:**
```json
{
"remediation_steps": "Step 1: ...\nStep 2: ...",
"remediation_status": "pending | in_progress | completed | not_applicable",
"remediation_assignee": "user-uuid"
}
```
#### `POST /api/v1/tests/{id}/start-execution`
Transition: `draft``red_executing`. Sets `execution_date`.
#### `POST /api/v1/tests/{id}/submit-red`
Transition: `red_executing``blue_evaluating`. Notifies all blue_tech users.
#### `POST /api/v1/tests/{id}/submit-blue`
Transition: `blue_evaluating``in_review`. Notifies red_lead and blue_lead.
#### `POST /api/v1/tests/{id}/validate-red`
Red Lead approves or rejects. Triggers dual validation check.
**Body:**
```json
{
"red_validation_status": "approved | rejected",
"red_validation_notes": "optional notes"
}
```
#### `POST /api/v1/tests/{id}/validate-blue`
Blue Lead approves or rejects. Triggers dual validation check.
**Body:**
```json
{
"blue_validation_status": "approved | rejected",
"blue_validation_notes": "optional notes"
}
```
#### `POST /api/v1/tests/{id}/reopen`
Move a `rejected` test back to `draft`. Clears all validation fields.
---
### Test Templates
#### `GET /api/v1/test-templates`
List templates with filters: `source`, `platform`, `severity`, `search`, `mitre_technique_id`, `is_active`.
#### `POST /api/v1/test-templates` (Admin)
Create a custom template with `suggested_remediation`.
#### `GET /api/v1/test-templates/stats` (Admin)
Returns catalog statistics: total, active, inactive, by source, by platform.
#### `POST /api/v1/test-templates/{id}/toggle-active` (Admin)
Toggle a template's active/inactive status.
---
### Notifications
#### `GET /api/v1/notifications`
List notifications for the current user (paginated, newest first).
**Query Parameters:** `offset` (default 0), `limit` (default 20, max 100)
#### `GET /api/v1/notifications/unread-count`
Returns `{ "unread_count": N }`.
#### `PATCH /api/v1/notifications/{id}/read`
Mark a single notification as read.
#### `POST /api/v1/notifications/read-all`
Mark all notifications for the current user as read.
**Automatic Notifications:**
- `red_executing` → notifies creator
- `blue_evaluating` → notifies all blue_tech users
- `in_review` → notifies red_lead and blue_lead
- `rejected` → notifies creator
- `validated` → notifies creator
---
### Reports
#### `GET /api/v1/reports/coverage-summary`
Full technique coverage report as JSON. Includes summary and technique-by-technique breakdown.
**Filters:** `tactic`, `platform`
#### `GET /api/v1/reports/coverage-csv`
Downloadable CSV of coverage data.
**Filters:** `tactic`, `platform`
#### `GET /api/v1/reports/test-results`
Test results report with state and detection breakdowns.
**Filters:** `state`, `date_from` (ISO), `date_to` (ISO)
#### `GET /api/v1/reports/remediation-status`
Remediation status report across all tests with assigned steps.
**Filter:** `status` (pending, in_progress, completed, not_applicable)
---
### V2 Metrics
#### `GET /api/v1/metrics/test-pipeline`
Test counts by state across the pipeline.
#### `GET /api/v1/metrics/team-activity`
Red/Blue team activity: tests completed, pending.
#### `GET /api/v1/metrics/validation-rate`
Approval/rejection rates for Red Lead and Blue Lead.
#### `GET /api/v1/metrics/recent-tests`
Last 10 most recently updated tests.
---
## Error Responses ## Error Responses
All errors follow a consistent format: All errors follow a consistent format:
@@ -376,8 +583,22 @@ All errors follow a consistent format:
} }
``` ```
State transition errors include additional context:
```json
{
"detail": {
"message": "Cannot transition from 'draft' to 'validated'. Valid transitions: ['red_executing']",
"code": "INVALID_TRANSITION",
"current_state": "draft",
"target_state": "validated",
"valid_transitions": ["red_executing"]
}
}
```
Common HTTP status codes: Common HTTP status codes:
- `400` - Bad Request (validation error, invalid input) - `400` - Bad Request (validation error, invalid transition, invalid input)
- `401` - Unauthorized (missing or invalid token) - `401` - Unauthorized (missing or invalid token)
- `403` - Forbidden (insufficient permissions) - `403` - Forbidden (insufficient permissions)
- `404` - Not Found (resource doesn't exist) - `404` - Not Found (resource doesn't exist)

View File

@@ -7,6 +7,7 @@ import TestsPage from "./pages/TestsPage";
import TestCreatePage from "./pages/TestCreatePage"; import TestCreatePage from "./pages/TestCreatePage";
import TestDetailPage from "./pages/TestDetailPage"; import TestDetailPage from "./pages/TestDetailPage";
import TestCatalogPage from "./pages/TestCatalogPage"; import TestCatalogPage from "./pages/TestCatalogPage";
import ReportsPage from "./pages/ReportsPage";
import SystemPage from "./pages/SystemPage"; import SystemPage from "./pages/SystemPage";
import UsersPage from "./pages/UsersPage"; import UsersPage from "./pages/UsersPage";
import AuditLogPage from "./pages/AuditLogPage"; import AuditLogPage from "./pages/AuditLogPage";
@@ -35,6 +36,7 @@ export default function App() {
<Route path="/tests/:testId" element={<TestDetailPage />} /> <Route path="/tests/:testId" element={<TestDetailPage />} />
<Route path="/test-catalog" element={<TestCatalogPage />} /> <Route path="/test-catalog" element={<TestCatalogPage />} />
<Route path="/test-catalog/:templateId/use" element={<TestCatalogPage />} /> <Route path="/test-catalog/:templateId/use" element={<TestCatalogPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route <Route
path="/system" path="/system"
element={ element={

View File

@@ -0,0 +1,78 @@
import { AlertTriangle, Loader2 } from "lucide-react";
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "warning" | "default";
isLoading?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
const variantStyles = {
danger: {
icon: "text-red-400 bg-red-500/10",
button: "bg-red-600 hover:bg-red-500",
},
warning: {
icon: "text-yellow-400 bg-yellow-500/10",
button: "bg-yellow-600 hover:bg-yellow-500",
},
default: {
icon: "text-cyan-400 bg-cyan-500/10",
button: "bg-cyan-600 hover:bg-cyan-500",
},
};
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
isLoading = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!open) return null;
const styles = variantStyles[variant];
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-md rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-2xl">
<div className="flex items-start gap-4">
<div className={`rounded-lg p-2 ${styles.icon}`}>
<AlertTriangle className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-gray-400">{message}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onCancel}
disabled={isLoading}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-800 disabled:opacity-50"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 ${styles.button}`}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,34 +1,110 @@
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { useState } from "react";
import { import {
LayoutDashboard, LayoutDashboard,
Shield, Shield,
FlaskConical, FlaskConical,
BookOpen, BookOpen,
BarChart3,
Settings, Settings,
Users, Users,
FileText, FileText,
ChevronDown,
ListChecks,
ClipboardList,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
const baseLinks = [ interface NavItem {
to: string;
label: string;
icon: React.FC<{ className?: string }>;
children?: NavItem[];
}
const mainLinks: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/techniques", label: "Techniques", icon: Shield }, { to: "/techniques", label: "ATT&CK Matrix", icon: Shield },
{ to: "/tests", label: "Tests", icon: FlaskConical }, {
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen }, to: "/tests",
label: "Tests",
icon: FlaskConical,
children: [
{ to: "/tests", label: "All Tests", icon: ListChecks },
{ to: "/tests?view=pending", label: "My Pending Tasks", icon: ClipboardList },
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen },
],
},
{ to: "/reports", label: "Reports", icon: BarChart3 },
]; ];
const adminLinks = [ const adminLinks: NavItem[] = [
{ to: "/users", label: "Users", icon: Users }, { to: "/users", label: "Users", icon: Users },
{ to: "/audit", label: "Audit Log", icon: FileText }, { to: "/audit", label: "Audit Log", icon: FileText },
{ to: "/system", label: "System", icon: Settings }, { to: "/system", label: "System", icon: Settings },
]; ];
function SidebarLink({ item }: { item: NavItem }) {
const [expanded, setExpanded] = useState(false);
if (item.children) {
return (
<div>
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm font-medium text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
>
<span className="flex items-center gap-3">
<item.icon className="h-5 w-5" />
{item.label}
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`} />
</button>
{expanded && (
<div className="ml-4 mt-1 space-y-0.5 border-l border-gray-800 pl-3">
{item.children.map((child) => (
<NavLink
key={child.to + child.label}
to={child.to}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
isActive
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-500 hover:bg-gray-800 hover:text-gray-200"
}`
}
>
<child.icon className="h-4 w-4" />
{child.label}
</NavLink>
))}
</div>
)}
</div>
);
}
return (
<NavLink
to={item.to}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
}`
}
>
<item.icon className="h-5 w-5" />
{item.label}
</NavLink>
);
}
export default function Sidebar() { export default function Sidebar() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
const links = isAdmin ? [...baseLinks, ...adminLinks] : baseLinks;
return ( return (
<aside className="flex h-screen w-60 flex-col border-r border-gray-800 bg-gray-900"> <aside className="flex h-screen w-60 flex-col border-r border-gray-800 bg-gray-900">
{/* Logo */} {/* Logo */}
@@ -39,24 +115,24 @@ export default function Sidebar() {
</span> </span>
</div> </div>
{/* Nav links */} {/* Main nav */}
<nav className="flex-1 space-y-1 px-3 py-4"> <nav className="flex-1 space-y-1 px-3 py-4">
{links.map(({ to, label, icon: Icon }) => ( {mainLinks.map((item) => (
<NavLink <SidebarLink key={item.to + item.label} item={item} />
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
}`
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))} ))}
{/* Admin section */}
{isAdmin && (
<>
<div className="my-3 border-t border-gray-800" />
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-widest text-gray-600">
Administration
</p>
{adminLinks.map((item) => (
<SidebarLink key={item.to} item={item} />
))}
</>
)}
</nav> </nav>
{/* Footer */} {/* Footer */}

View File

@@ -22,6 +22,7 @@ import type { TestResult, TeamSide, TestTimelineEntry } from "../types/models";
import TestDetailHeader from "../components/test-detail/TestDetailHeader"; import TestDetailHeader from "../components/test-detail/TestDetailHeader";
import TeamTabs from "../components/test-detail/TeamTabs"; import TeamTabs from "../components/test-detail/TeamTabs";
import ValidationModal from "../components/test-detail/ValidationModal"; import ValidationModal from "../components/test-detail/ValidationModal";
import ConfirmDialog from "../components/ConfirmDialog";
// ── Page Component ───────────────────────────────────────────────── // ── Page Component ─────────────────────────────────────────────────
@@ -38,6 +39,8 @@ export default function TestDetailPage() {
side: "red" | "blue"; side: "red" | "blue";
}>({ open: false, side: "red" }); }>({ open: false, side: "red" });
const [confirmReopen, setConfirmReopen] = useState(false);
const [redDraft, setRedDraft] = useState({ const [redDraft, setRedDraft] = useState({
procedure_text: "", procedure_text: "",
tool_used: "", tool_used: "",
@@ -96,7 +99,19 @@ export default function TestDetailPage() {
const showToast = useCallback((message: string, type: "success" | "error") => { const showToast = useCallback((message: string, type: "success" | "error") => {
setToast({ message, type }); setToast({ message, type });
setTimeout(() => setToast(null), 3500); setTimeout(() => setToast(null), 5000);
}, []);
/** Extract a user-friendly error message from Axios or generic errors. */
const extractError = useCallback((err: unknown): string => {
if (err && typeof err === "object" && "response" in err) {
const resp = (err as { response?: { data?: { detail?: string | { message?: string } } } }).response;
const detail = resp?.data?.detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && "message" in detail) return (detail as { message: string }).message;
}
if (err instanceof Error) return err.message;
return "An unexpected error occurred";
}, []); }, []);
const invalidateAll = useCallback(() => { const invalidateAll = useCallback(() => {
@@ -120,7 +135,7 @@ export default function TestDetailPage() {
invalidateAll(); invalidateAll();
showToast("Red Team fields saved", "success"); showToast("Red Team fields saved", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const saveBlueMutation = useMutation({ const saveBlueMutation = useMutation({
@@ -133,7 +148,7 @@ export default function TestDetailPage() {
invalidateAll(); invalidateAll();
showToast("Blue Team fields saved", "success"); showToast("Blue Team fields saved", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
// State transitions // State transitions
@@ -143,7 +158,7 @@ export default function TestDetailPage() {
invalidateAll(); invalidateAll();
showToast("Test execution started", "success"); showToast("Test execution started", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const submitRedMutation = useMutation({ const submitRedMutation = useMutation({
@@ -152,7 +167,7 @@ export default function TestDetailPage() {
invalidateAll(); invalidateAll();
showToast("Submitted to Blue Team", "success"); showToast("Submitted to Blue Team", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const submitBlueMutation = useMutation({ const submitBlueMutation = useMutation({
@@ -161,7 +176,7 @@ export default function TestDetailPage() {
invalidateAll(); invalidateAll();
showToast("Submitted for review", "success"); showToast("Submitted for review", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const validateRedLeadMutation = useMutation({ const validateRedLeadMutation = useMutation({
@@ -172,7 +187,7 @@ export default function TestDetailPage() {
setValidationModal({ open: false, side: "red" }); setValidationModal({ open: false, side: "red" });
showToast("Red Lead validation submitted", "success"); showToast("Red Lead validation submitted", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const validateBlueLeadMutation = useMutation({ const validateBlueLeadMutation = useMutation({
@@ -183,16 +198,20 @@ export default function TestDetailPage() {
setValidationModal({ open: false, side: "blue" }); setValidationModal({ open: false, side: "blue" });
showToast("Blue Lead validation submitted", "success"); showToast("Blue Lead validation submitted", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const reopenMutation = useMutation({ const reopenMutation = useMutation({
mutationFn: () => reopenTest(testId!), mutationFn: () => reopenTest(testId!),
onSuccess: () => { onSuccess: () => {
invalidateAll(); invalidateAll();
setConfirmReopen(false);
showToast("Test reopened", "success"); showToast("Test reopened", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => {
setConfirmReopen(false);
showToast(extractError(err), "error");
},
}); });
// Evidence upload // Evidence upload
@@ -203,7 +222,7 @@ export default function TestDetailPage() {
invalidateAll(); invalidateAll();
showToast("Evidence uploaded", "success"); showToast("Evidence uploaded", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
// ── Handlers ─────────────────────────────────────────────────── // ── Handlers ───────────────────────────────────────────────────
@@ -322,7 +341,7 @@ export default function TestDetailPage() {
onSubmitRed={() => submitRedMutation.mutate()} onSubmitRed={() => submitRedMutation.mutate()}
onSubmitBlue={() => submitBlueMutation.mutate()} onSubmitBlue={() => submitBlueMutation.mutate()}
onOpenValidateModal={(side) => setValidationModal({ open: true, side })} onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
onReopen={() => reopenMutation.mutate()} onReopen={() => setConfirmReopen(true)}
/> />
{/* Content: Tabs + Sidebar */} {/* Content: Tabs + Sidebar */}
@@ -426,6 +445,18 @@ export default function TestDetailPage() {
</div> </div>
</div> </div>
{/* Confirm Reopen Dialog */}
<ConfirmDialog
open={confirmReopen}
title="Reopen Test"
message="This will move the test back to Draft state and clear all validation decisions. The Red/Blue workflow will need to be restarted. Are you sure?"
confirmLabel="Reopen"
variant="warning"
isLoading={reopenMutation.isPending}
onConfirm={() => reopenMutation.mutate()}
onCancel={() => setConfirmReopen(false)}
/>
{/* Validation Modal */} {/* Validation Modal */}
{validationModal.open && ( {validationModal.open && (
<ValidationModal <ValidationModal