feat(phase-20): navigation, error handling, integration tests, and V2 docs (T-132 to T-135)
This commit is contained in:
400
README.md
400
README.md
@@ -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,37 +114,26 @@ 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.
|
||||||
@@ -88,16 +141,16 @@ curl http://localhost:8000/api/v1/auth/me \
|
|||||||
## 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/ # b001–b007 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
|
|
||||||
│ │ ├── user.py # User authentication model
|
|
||||||
│ │ ├── technique.py # MITRE ATT&CK techniques
|
│ │ ├── technique.py # MITRE ATT&CK techniques
|
||||||
│ │ ├── test.py # Security tests
|
│ │ ├── test.py # Tests with Red/Blue + remediation fields
|
||||||
│ │ ├── evidence.py # Test evidence files
|
│ │ ├── test_template.py # Template catalog
|
||||||
│ │ ├── intel.py # Threat intelligence items
|
│ │ ├── evidence.py # Evidence files (team-separated)
|
||||||
|
│ │ ├── notification.py # In-app notifications
|
||||||
|
│ │ ├── 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.
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
696
backend/tests/test_integration_v2.py
Normal file
696
backend/tests/test_integration_v2.py
Normal 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!")
|
||||||
223
docs/API.md
223
docs/API.md
@@ -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)
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
78
frontend/src/components/ConfirmDialog.tsx
Normal file
78
frontend/src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "/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: "/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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user