feat: sprint 1 — auth + CRUD engagements
Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.
Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling
Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
for already-authed users, Fragment-keyed admin user rows)
Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data
Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)
Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
resolves the `backend.app.*` absolute imports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Mimic — environment variables
|
||||||
|
# Copy this file to `.env` and fill in real values before `make start`.
|
||||||
|
# `.env` is gitignored — never commit secrets.
|
||||||
|
|
||||||
|
# --- Required ---
|
||||||
|
|
||||||
|
# JWT signing secret. Generate with: openssl rand -hex 32
|
||||||
|
# The backend will refuse to start in production if this is unset.
|
||||||
|
MIMIC_JWT_SECRET=replace-me-with-a-strong-random-secret
|
||||||
|
|
||||||
|
# --- Optional (defaults shown) ---
|
||||||
|
|
||||||
|
# Path where SQLite stores the database, INSIDE the container.
|
||||||
|
# Must live under the /data volume mount to persist across `make restart`.
|
||||||
|
MIMIC_DB_PATH=/data/mimic.sqlite
|
||||||
|
|
||||||
|
# Port the Flask app listens on inside the container.
|
||||||
|
# To expose on a different host port, override PORT when calling make:
|
||||||
|
# make start PORT=8080
|
||||||
|
MIMIC_PORT=5000
|
||||||
|
|
||||||
|
# --- Sprint 2+ (not used in Sprint 1) ---
|
||||||
|
# MITRE_BUNDLE_PATH=/app/backend/data/mitre/enterprise-attack.json
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -89,3 +89,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# --- MITRE bundle if huge (kept by default — uncomment to ignore) ---
|
# --- MITRE bundle if huge (kept by default — uncomment to ignore) ---
|
||||||
# backend/data/mitre/enterprise-attack.json
|
# backend/data/mitre/enterprise-attack.json
|
||||||
|
|
||||||
|
# TypeScript build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -6,14 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added — Sprint 1 (Auth + CRUD Engagement)
|
||||||
- Initial `SPEC.md` covering project scope, simulation model, workflow, stack, and agent team.
|
|
||||||
- Technical decisions section in `SPEC.md`: 3-role auth (admin/redteam/soc), JWT Bearer, single-container Flask+React, local MITRE STIX bundle, minimal Engagement model, admin bootstrap via Makefile target.
|
**Backend** (Flask + SQLAlchemy + SQLite, 63 pytest passing)
|
||||||
- Sub-agent definitions under `.claude/agents/` for backend-builder, frontend-builder, spec-reviewer (project override of the built-in, covers plan-vs-spec and code-vs-spec), code-reviewer, test-verifier, devil-advocate.
|
- `User` model with `admin / redteam / soc` enum, argon2 password hashing.
|
||||||
- Project tracking scaffold: `tasks/todo.md`, `tasks/lessons.md`, `CHANGELOG.md`, `.gitignore`.
|
- `Engagement` model with `planned / active / closed` status, FK to creator user.
|
||||||
|
- JWT Bearer auth (`PyJWT`, HS256, 60-min TTL), `@login_required` and `@role_required(*roles)` decorators.
|
||||||
|
- 13 API endpoints: `/api/auth/{login,logout,me}`, `/api/users` CRUD (admin-only with last-admin protection), `/api/engagements` CRUD (RBAC per role), `/api/health`.
|
||||||
|
- Alembic migration applied at container boot by `docker/entrypoint.sh`.
|
||||||
|
- `flask create-admin` CLI with duplicate-username and short-password validation.
|
||||||
|
- Engagement serializer returns `created_by={id, username}` (not bare User object).
|
||||||
|
- SPA fallback returns JSON 404 for unknown `/api/*` paths (no HTML leakage).
|
||||||
|
|
||||||
|
**Frontend** (React + Vite + TailwindCSS + TanStack Query, 20 vitest passing)
|
||||||
|
- Inter font bundled locally via `@fontsource-variable/inter` (no CDN at runtime).
|
||||||
|
- Tailwind config maps the `DESIGN.md` token system (palette, typography, spacing, radii).
|
||||||
|
- Pages: `LoginPage`, `EngagementsListPage`, `EngagementFormPage` (new+edit), `EngagementDetailPage` (Sprint 2 placeholder), `UsersAdminPage`.
|
||||||
|
- Components: `Layout`, `ProtectedRoute` (auth + role gate), `StatusBadge`, `FormField`, `LoadingState`/`ErrorState`/`EmptyState`, `Toast` + provider.
|
||||||
|
- Axios client with Bearer interceptor; 401 → token purge + redirect `/login` + "Session expirée" toast (AC-2.6); 403 → "Accès refusé" toast (AC-3.7).
|
||||||
|
- TanStack Query hooks: `useAuth`, `useEngagements`, `useUsers`, `useToast`.
|
||||||
|
|
||||||
|
**Deployment**
|
||||||
|
- Single-container `docker/Dockerfile` (multistage: `node:20-alpine` → `python:3.12-slim`).
|
||||||
|
- `docker/entrypoint.sh` running `flask db upgrade && flask run`.
|
||||||
|
- `Makefile` with `build`, `start`, `stop`, `restart`, `update`, `logs`, `create-admin`, `update-mitre` (no-op placeholder for Sprint 2), `test-backend`, `test-frontend`, `test-e2e`, `clean`.
|
||||||
|
- `.env.example` documenting `MIMIC_JWT_SECRET`, `MIMIC_DB_PATH`, `MIMIC_PORT`.
|
||||||
|
- SQLite persisted at `/data/mimic.sqlite`, volume `mimic-data` survives `make restart`.
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright, 36 specs, all 27 ACs covered)
|
||||||
|
- `e2e/` scaffold: `playwright.config.ts`, `fixtures/{auth,api}.ts`, 6 spec files (one per user story).
|
||||||
|
- Suite is portable via `MIMIC_CONTAINER_CMD` / `MIMIC_BASE_URL` env vars (works with `docker` or `podman`).
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- `README.md` with quick-start, architecture overview, project layout, make target reference, and dev workflow.
|
||||||
|
- `pyrightconfig.json` at repo root pointing the Python LSP to `backend/.venv` and adding the worktree root to `extraPaths` for absolute imports.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- 2026-05-26 — `admin` role widened in `SPEC.md` § Décisions techniques. The initial draft restricted admin to user-management only; after the Sprint 1 plan review surfaced the operational pain (admin would need a second `redteam` account just to manage engagements), the user decided to make admin a super-user that cumulates redteam rights on engagements/simulations.
|
- 2026-05-26 — `admin` role widened in `SPEC.md` § Décisions techniques. The initial draft restricted admin to user-management only; after the Sprint 1 plan review surfaced the operational pain (admin would need a second `redteam` account just to manage engagements), the user decided to make admin a super-user that cumulates redteam rights on engagements/simulations.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- _none_
|
- _none_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 0] — Bootstrap (merged 2026-05-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial `SPEC.md` covering project scope, simulation model, workflow, stack, and agent team.
|
||||||
|
- Technical decisions section in `SPEC.md`: 3-role auth (admin/redteam/soc), JWT Bearer, single-container Flask+React, local MITRE STIX bundle, minimal Engagement model, admin bootstrap via Makefile target.
|
||||||
|
- Sub-agent definitions under `.claude/agents/` for backend-builder, frontend-builder, spec-reviewer (project override of the built-in, covers plan-vs-spec and code-vs-spec), code-reviewer, test-verifier, devil-advocate.
|
||||||
|
- Project tracking scaffold: `tasks/todo.md`, `tasks/lessons.md`, `CHANGELOG.md`, `.gitignore`.
|
||||||
|
|||||||
50
Makefile
Normal file
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
PORT ?= 5000
|
||||||
|
IMAGE ?= mimic:latest
|
||||||
|
CONTAINER ?= mimic
|
||||||
|
VOLUME ?= mimic-data
|
||||||
|
|
||||||
|
.PHONY: build start stop restart update logs create-admin update-mitre test-backend test-frontend test-e2e clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker build -f docker/Dockerfile -t $(IMAGE) .
|
||||||
|
|
||||||
|
start:
|
||||||
|
docker run -d --name $(CONTAINER) -p $(PORT):5000 -v $(VOLUME):/data --env-file .env $(IMAGE)
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker stop $(CONTAINER) && docker rm $(CONTAINER)
|
||||||
|
|
||||||
|
restart:
|
||||||
|
$(MAKE) stop && $(MAKE) start
|
||||||
|
|
||||||
|
update:
|
||||||
|
git pull && $(MAKE) build && $(MAKE) restart
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker logs -f $(CONTAINER)
|
||||||
|
|
||||||
|
create-admin:
|
||||||
|
ifndef USER
|
||||||
|
$(error USER is required: make create-admin USER=alice PASS=p4ssw0rd)
|
||||||
|
endif
|
||||||
|
ifndef PASS
|
||||||
|
$(error PASS is required: make create-admin USER=alice PASS=p4ssw0rd)
|
||||||
|
endif
|
||||||
|
docker exec $(CONTAINER) flask create-admin $(USER) $(PASS)
|
||||||
|
|
||||||
|
update-mitre:
|
||||||
|
@echo "MITRE update: Sprint 2+"
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
docker exec $(CONTAINER) pytest -q backend/tests/
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
cd frontend && npm run test -- --run
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
cd e2e && npx playwright test
|
||||||
|
|
||||||
|
clean:
|
||||||
|
-docker rm -f $(CONTAINER) 2>/dev/null
|
||||||
|
-docker volume rm $(VOLUME) 2>/dev/null
|
||||||
|
rm -rf backend/__pycache__ frontend/node_modules frontend/dist
|
||||||
156
README.md
Normal file
156
README.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Mimic
|
||||||
|
|
||||||
|
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
|
||||||
|
|
||||||
|
> Status: **Sprint 1 — Auth + CRUD Engagement**. Simulation workflow and MITRE TTP autocomplete arrive in Sprint 2+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Prerequisites: Docker (or Podman) + GNU Make. Linux/macOS host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configure secrets
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and set MIMIC_JWT_SECRET to a strong random value:
|
||||||
|
# sed -i "s|replace-me-with-a-strong-random-secret|$(openssl rand -hex 32)|" .env
|
||||||
|
|
||||||
|
# 2. Build and start the container
|
||||||
|
make build
|
||||||
|
make start
|
||||||
|
|
||||||
|
# 3. Bootstrap the first admin (run once, the container must be up)
|
||||||
|
make create-admin USER=alice PASS=changeme8
|
||||||
|
|
||||||
|
# 4. Open the UI
|
||||||
|
xdg-open http://localhost:5000 # Linux
|
||||||
|
# or visit http://localhost:5000 manually
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in with the credentials from step 3. The admin can create additional users (redteam / soc) from `/admin/users`.
|
||||||
|
|
||||||
|
To stop or restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
make restart # stop + start, preserves the SQLite volume
|
||||||
|
make logs # tail container logs
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the host port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single-container deployment. A multistage Dockerfile builds the Vite frontend, then copies the static assets into the Flask backend image so Flask serves both the API (under `/api/*`) and the SPA (everything else).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
│ Container: mimic:latest │
|
||||||
|
│ │
|
||||||
|
│ Flask (Python 3.12) │
|
||||||
|
│ ├── /api/* ── blueprints (auth, users, │
|
||||||
|
│ │ engagements) │
|
||||||
|
│ └── / ── SPA fallback → React build │
|
||||||
|
│ │
|
||||||
|
│ SQLAlchemy ── SQLite at /data/mimic.sqlite │
|
||||||
|
│ (volume: mimic-data)│
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Auth**: JWT Bearer tokens (HS256, 60-min TTL). Stateless — no refresh tokens, no server-side session.
|
||||||
|
- **Roles**: `admin` (super-user, manages users + engagements), `redteam` (CRUD engagements + simulations), `soc` (read engagements; will write the SOC half of simulations in Sprint 2).
|
||||||
|
- **Password hashing**: argon2 via `argon2-cffi`.
|
||||||
|
- **Migrations**: Alembic, applied automatically by the container entrypoint (`flask db upgrade && flask run`).
|
||||||
|
|
||||||
|
See [`SPEC.md`](SPEC.md) § "Décisions techniques" for the full architecture rationale and [`DESIGN.md`](DESIGN.md) for the UI design system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mimic/
|
||||||
|
├── backend/ # Flask app, SQLAlchemy models, Alembic migrations, pytest suite
|
||||||
|
├── frontend/ # Vite + React + Tailwind + TanStack Query, Vitest suite
|
||||||
|
├── e2e/ # Playwright acceptance tests (one spec per user story)
|
||||||
|
├── docker/ # Dockerfile (multistage) + entrypoint.sh
|
||||||
|
├── tasks/ # Sprint plans (tasks/todo.md) and lessons (tasks/lessons.md)
|
||||||
|
├── .claude/agents/ # Sub-agent definitions for the team (read-only at runtime)
|
||||||
|
├── Makefile # all operational entry points
|
||||||
|
├── SPEC.md # functional + technical spec
|
||||||
|
├── DESIGN.md # UI design system (palette, typography, components)
|
||||||
|
└── CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Make targets
|
||||||
|
|
||||||
|
| Target | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `make build` | Build the `mimic:latest` Docker image (multistage: Node → Python) |
|
||||||
|
| `make start` | Start the container (port from `PORT`, default 5000; mounts `mimic-data` volume) |
|
||||||
|
| `make stop` | Stop and remove the container |
|
||||||
|
| `make restart` | `make stop && make start` — preserves the SQLite volume |
|
||||||
|
| `make update` | `git pull && make build && make restart` |
|
||||||
|
| `make logs` | `docker logs -f mimic` |
|
||||||
|
| `make create-admin USER=… PASS=…` | Run `flask create-admin` inside the container |
|
||||||
|
| `make update-mitre` | No-op placeholder — Sprint 2+ will fetch the MITRE STIX bundle |
|
||||||
|
| `make test-backend` | `pytest -q` inside the container |
|
||||||
|
| `make test-frontend` | `npm run test -- --run` in `frontend/` |
|
||||||
|
| `make test-e2e` | Playwright acceptance suite (container must be running) |
|
||||||
|
| `make clean` | Remove container + volume + Python/Node caches |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development (without Docker)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export MIMIC_JWT_SECRET=dev-secret
|
||||||
|
export MIMIC_DB_PATH=./mimic.sqlite
|
||||||
|
flask --app backend.app:create_app db upgrade
|
||||||
|
flask --app backend.app:create_app run --port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 with /api proxied to :5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && pytest -q # 63 tests
|
||||||
|
cd frontend && npm run test -- --run # 20 tests
|
||||||
|
cd e2e && npx playwright test # 36 tests (needs container up)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`SPEC.md`](SPEC.md) — functional spec, technical decisions, agent team
|
||||||
|
- [`DESIGN.md`](DESIGN.md) — UI design system
|
||||||
|
- [`CHANGELOG.md`](CHANGELOG.md) — sprint-by-sprint changes
|
||||||
|
- [`tasks/todo.md`](tasks/todo.md) — current sprint plan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Internal project — not yet open-sourced.
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
71
backend/app/__init__.py
Normal file
71
backend/app/__init__.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Flask application factory."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, send_from_directory
|
||||||
|
|
||||||
|
from backend.app.api import auth_bp, engagements_bp, users_bp
|
||||||
|
from backend.app.cli import register_cli
|
||||||
|
from backend.app.config import Config, TestConfig
|
||||||
|
from backend.app.errors import register_error_handlers
|
||||||
|
from backend.app.extensions import db, migrate
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_object: object | None = None) -> Flask:
|
||||||
|
"""Application factory.
|
||||||
|
|
||||||
|
`config_object` is an *instance* (not a class) of Config or a subclass.
|
||||||
|
If None, picks TestConfig when MIMIC_TESTING=1, otherwise Config.
|
||||||
|
"""
|
||||||
|
static_folder = str(Path(__file__).parent / "static")
|
||||||
|
app = Flask(__name__, static_folder=static_folder, static_url_path="/static")
|
||||||
|
|
||||||
|
if config_object is None:
|
||||||
|
config_object = TestConfig() if os.environ.get("MIMIC_TESTING") == "1" else Config()
|
||||||
|
app.config.from_object(config_object)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
migrations_dir = str(Path(__file__).parent.parent / "migrations")
|
||||||
|
migrate.init_app(app, db, directory=migrations_dir)
|
||||||
|
|
||||||
|
# Ensure models are imported so Alembic/metadata see them.
|
||||||
|
from backend.app import models # noqa: F401
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(users_bp)
|
||||||
|
app.register_blueprint(engagements_bp)
|
||||||
|
|
||||||
|
register_error_handlers(app)
|
||||||
|
register_cli(app)
|
||||||
|
|
||||||
|
static_root = Path(static_folder)
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}, 200
|
||||||
|
|
||||||
|
# Serve the built frontend (Vite output copied into app/static at image build time).
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
index_path = static_root / "index.html"
|
||||||
|
if index_path.exists():
|
||||||
|
return send_from_directory(static_folder, "index.html")
|
||||||
|
return {"status": "ok", "message": "Mimic API running. Frontend not built."}, 200
|
||||||
|
|
||||||
|
@app.get("/<path:path>")
|
||||||
|
def spa_fallback(path: str):
|
||||||
|
# Unknown /api/* paths must stay JSON 404 — never shadowed by index.html.
|
||||||
|
if path.startswith("api/"):
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
# Serve static assets if present; otherwise hand back index.html for client routing.
|
||||||
|
candidate = static_root / path
|
||||||
|
if candidate.is_file():
|
||||||
|
return send_from_directory(static_folder, path)
|
||||||
|
index_path = static_root / "index.html"
|
||||||
|
if index_path.exists():
|
||||||
|
return send_from_directory(static_folder, "index.html")
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
|
return app
|
||||||
6
backend/app/api/__init__.py
Normal file
6
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""API blueprints."""
|
||||||
|
from backend.app.api.auth import auth_bp
|
||||||
|
from backend.app.api.engagements import engagements_bp
|
||||||
|
from backend.app.api.users import users_bp
|
||||||
|
|
||||||
|
__all__ = ["auth_bp", "users_bp", "engagements_bp"]
|
||||||
46
backend/app/api/auth.py
Normal file
46
backend/app/api/auth.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Auth endpoints: login, logout, me."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import encode_token, login_required, verify_password
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.app.serializers import serialize_user
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/login")
|
||||||
|
def login():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
|
||||||
|
generic_error = (jsonify({"error": "Invalid credentials"}), 401)
|
||||||
|
if not username or not password:
|
||||||
|
return generic_error
|
||||||
|
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user is None or not verify_password(user.password_hash, password):
|
||||||
|
return generic_error
|
||||||
|
|
||||||
|
token = encode_token(user.id, user.role.value)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"access_token": token,
|
||||||
|
"user": {"id": user.id, "username": user.username, "role": user.role.value},
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
# V1: stateless JWT — client discards the token. No server-side blacklist.
|
||||||
|
return jsonify({"status": "ok"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.get("/me")
|
||||||
|
@login_required
|
||||||
|
def me():
|
||||||
|
return jsonify(serialize_user(g.current_user)), 200
|
||||||
158
backend/app/api/engagements.py
Normal file
158
backend/app/api/engagements.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Engagement CRUD endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import login_required, role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import Engagement, EngagementStatus
|
||||||
|
from backend.app.serializers import serialize_engagement
|
||||||
|
|
||||||
|
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: object) -> date | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status(value: object) -> EngagementStatus | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return EngagementStatus(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.get("")
|
||||||
|
@login_required
|
||||||
|
def list_engagements():
|
||||||
|
items = Engagement.query.order_by(Engagement.id.asc()).all()
|
||||||
|
return jsonify([serialize_engagement(e) for e in items]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.post("")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def create_engagement():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
|
||||||
|
start_raw = data.get("start_date")
|
||||||
|
start_date = _parse_date(start_raw) if start_raw else None
|
||||||
|
if start_date is None:
|
||||||
|
return jsonify({"error": "start_date is required (YYYY-MM-DD)"}), 400
|
||||||
|
|
||||||
|
end_raw = data.get("end_date")
|
||||||
|
end_date: date | None = None
|
||||||
|
if end_raw:
|
||||||
|
end_date = _parse_date(end_raw)
|
||||||
|
if end_date is None:
|
||||||
|
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
||||||
|
if end_date < start_date:
|
||||||
|
return jsonify({"error": "end_date must be >= start_date"}), 400
|
||||||
|
|
||||||
|
status = EngagementStatus.PLANNED
|
||||||
|
if "status" in data and data.get("status") is not None:
|
||||||
|
parsed = _parse_status(data.get("status"))
|
||||||
|
if parsed is None:
|
||||||
|
return (
|
||||||
|
jsonify({"error": "status must be one of: planned, active, closed"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
status = parsed
|
||||||
|
|
||||||
|
engagement = Engagement(
|
||||||
|
name=name,
|
||||||
|
description=data.get("description"),
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
status=status,
|
||||||
|
created_by_id=g.current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(engagement)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_engagement(engagement)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.get("/<int:engagement_id>")
|
||||||
|
@login_required
|
||||||
|
def get_engagement(engagement_id: int):
|
||||||
|
engagement = db.session.get(Engagement, engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
return jsonify(serialize_engagement(engagement)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.patch("/<int:engagement_id>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def update_engagement(engagement_id: int):
|
||||||
|
engagement = db.session.get(Engagement, engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if "name" in data:
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name must not be empty"}), 400
|
||||||
|
engagement.name = name
|
||||||
|
|
||||||
|
if "description" in data:
|
||||||
|
engagement.description = data.get("description")
|
||||||
|
|
||||||
|
new_start = engagement.start_date
|
||||||
|
if "start_date" in data:
|
||||||
|
parsed = _parse_date(data.get("start_date"))
|
||||||
|
if parsed is None:
|
||||||
|
return jsonify({"error": "start_date must be YYYY-MM-DD"}), 400
|
||||||
|
new_start = parsed
|
||||||
|
|
||||||
|
new_end = engagement.end_date
|
||||||
|
if "end_date" in data:
|
||||||
|
if data.get("end_date") in (None, ""):
|
||||||
|
new_end = None
|
||||||
|
else:
|
||||||
|
parsed = _parse_date(data.get("end_date"))
|
||||||
|
if parsed is None:
|
||||||
|
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
||||||
|
new_end = parsed
|
||||||
|
|
||||||
|
if new_end is not None and new_end < new_start:
|
||||||
|
return jsonify({"error": "end_date must be >= start_date"}), 400
|
||||||
|
|
||||||
|
engagement.start_date = new_start
|
||||||
|
engagement.end_date = new_end
|
||||||
|
|
||||||
|
if "status" in data:
|
||||||
|
parsed_status = _parse_status((data.get("status") or "").strip())
|
||||||
|
if parsed_status is None:
|
||||||
|
return (
|
||||||
|
jsonify({"error": "status must be one of: planned, active, closed"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
engagement.status = parsed_status
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_engagement(engagement)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.delete("/<int:engagement_id>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def delete_engagement(engagement_id: int):
|
||||||
|
engagement = db.session.get(Engagement, engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
db.session.delete(engagement)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
106
backend/app/api/users.py
Normal file
106
backend/app/api/users.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""User management endpoints (admin only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password, role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
from backend.app.serializers import serialize_user
|
||||||
|
|
||||||
|
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_role(value: object) -> UserRole | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UserRole(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.get("")
|
||||||
|
@role_required("admin")
|
||||||
|
def list_users():
|
||||||
|
users = User.query.order_by(User.id.asc()).all()
|
||||||
|
return jsonify([serialize_user(u) for u in users]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.post("")
|
||||||
|
@role_required("admin")
|
||||||
|
def create_user():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
role_raw = (data.get("role") or "").strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return jsonify({"error": "username is required"}), 400
|
||||||
|
|
||||||
|
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||||
|
if len(password) < min_len:
|
||||||
|
return jsonify({"error": f"password must be at least {min_len} characters"}), 400
|
||||||
|
|
||||||
|
role = _parse_role(role_raw)
|
||||||
|
if role is None:
|
||||||
|
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first() is not None:
|
||||||
|
return jsonify({"error": "username already exists"}), 400
|
||||||
|
|
||||||
|
user = User(username=username, password_hash=hash_password(password), role=role)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_user(user)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.patch("/<int:user_id>")
|
||||||
|
@role_required("admin")
|
||||||
|
def update_user(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if "role" in data:
|
||||||
|
new_role = _parse_role((data.get("role") or "").strip())
|
||||||
|
if new_role is None:
|
||||||
|
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
|
||||||
|
# Refuse to demote the last admin.
|
||||||
|
if user.role == UserRole.ADMIN and new_role != UserRole.ADMIN:
|
||||||
|
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
|
||||||
|
if admin_count <= 1:
|
||||||
|
return jsonify({"error": "Cannot demote the last admin"}), 409
|
||||||
|
user.role = new_role
|
||||||
|
|
||||||
|
if "password" in data:
|
||||||
|
password = data.get("password") or ""
|
||||||
|
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||||
|
if len(password) < min_len:
|
||||||
|
return (
|
||||||
|
jsonify({"error": f"password must be at least {min_len} characters"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_user(user)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.delete("/<int:user_id>")
|
||||||
|
@role_required("admin")
|
||||||
|
def delete_user(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
|
||||||
|
if admin_count <= 1:
|
||||||
|
return jsonify({"error": "Cannot delete the last admin"}), 409
|
||||||
|
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
13
backend/app/auth/__init__.py
Normal file
13
backend/app/auth/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Auth helpers (JWT, hashing, decorators)."""
|
||||||
|
from backend.app.auth.decorators import login_required, role_required
|
||||||
|
from backend.app.auth.hashing import hash_password, verify_password
|
||||||
|
from backend.app.auth.jwt import decode_token, encode_token
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"hash_password",
|
||||||
|
"verify_password",
|
||||||
|
"encode_token",
|
||||||
|
"decode_token",
|
||||||
|
"login_required",
|
||||||
|
"role_required",
|
||||||
|
]
|
||||||
67
backend/app/auth/decorators.py
Normal file
67
backend/app/auth/decorators.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Auth decorators that gate routes on a valid JWT and (optionally) role."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth.jwt import decode_token
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token() -> str | None:
|
||||||
|
header = request.headers.get("Authorization", "")
|
||||||
|
if not header.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
return header.removeprefix("Bearer ").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
"""Require a valid JWT. Populates `g.current_user` with the User row."""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
token = _extract_token()
|
||||||
|
if not token:
|
||||||
|
return jsonify({"error": "Missing or invalid Authorization header"}), 401
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({"error": "Token expired"}), 401
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return jsonify({"error": "Invalid token"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return jsonify({"error": "Invalid token payload"}), 401
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"error": "User no longer exists"}), 401
|
||||||
|
|
||||||
|
g.current_user = user
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def role_required(*roles: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
"""Require the current user to hold one of the given role names."""
|
||||||
|
|
||||||
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
@wraps(fn)
|
||||||
|
@login_required
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
user = g.current_user
|
||||||
|
if user.role.value not in roles:
|
||||||
|
return jsonify({"error": "Forbidden"}), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
23
backend/app/auth/hashing.py
Normal file
23
backend/app/auth/hashing.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Password hashing using argon2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Return an argon2 hash of `password`."""
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
"""Return True iff `password` matches `password_hash`."""
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
# Malformed hash or other argon2 error — treat as auth failure.
|
||||||
|
return False
|
||||||
34
backend/app/auth/jwt.py
Normal file
34
backend/app/auth/jwt.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""JWT encode/decode helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
def encode_token(user_id: int, role: str) -> str:
|
||||||
|
"""Return a signed JWT for the given user."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
exp_minutes = current_app.config["JWT_EXP_MINUTES"]
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(minutes=exp_minutes)).timestamp()),
|
||||||
|
}
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
current_app.config["JWT_SECRET"],
|
||||||
|
algorithm=current_app.config["JWT_ALGORITHM"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
"""Decode + validate a JWT. Raises jwt.PyJWTError on failure."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
current_app.config["JWT_SECRET"],
|
||||||
|
algorithms=[current_app.config["JWT_ALGORITHM"]],
|
||||||
|
)
|
||||||
38
backend/app/cli.py
Normal file
38
backend/app/cli.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Flask CLI commands."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask import Flask, current_app
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli(app: Flask) -> None:
|
||||||
|
@app.cli.command("create-admin")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.argument("password")
|
||||||
|
def create_admin(username: str, password: str) -> None:
|
||||||
|
"""Create an admin user. Used to bootstrap the first account."""
|
||||||
|
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||||
|
if len(password) < min_len:
|
||||||
|
click.echo(
|
||||||
|
f"Error: password must be at least {min_len} characters.", err=True
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first() is not None:
|
||||||
|
click.echo(f"Error: username '{username}' already exists.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"Admin user '{username}' created (id={user.id}).")
|
||||||
35
backend/app/config.py
Normal file
35
backend/app/config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Application configuration loaded from environment variables."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Base configuration. Reads from env vars; fails loud on missing secrets."""
|
||||||
|
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXP_MINUTES = 60
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
db_path = os.environ.get("MIMIC_DB_PATH", "/data/mimic.sqlite")
|
||||||
|
self.SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
jwt_secret = os.environ.get("MIMIC_JWT_SECRET")
|
||||||
|
if not jwt_secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
"MIMIC_JWT_SECRET environment variable is required but not set."
|
||||||
|
)
|
||||||
|
self.JWT_SECRET = jwt_secret
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig(Config):
|
||||||
|
"""Config for pytest. Uses in-memory SQLite + fixed JWT secret."""
|
||||||
|
|
||||||
|
TESTING = True
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Bypass parent's env requirement; tests inject their own secret.
|
||||||
|
self.SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||||
|
self.JWT_SECRET = "test-secret-do-not-use-in-prod"
|
||||||
21
backend/app/errors.py
Normal file
21
backend/app/errors.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Uniform JSON error handlers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app: Flask) -> None:
|
||||||
|
@app.errorhandler(HTTPException)
|
||||||
|
def handle_http_exception(exc: HTTPException):
|
||||||
|
response = jsonify({"error": exc.description})
|
||||||
|
response.status_code = exc.code or 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def handle_404(_exc):
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(405)
|
||||||
|
def handle_405(_exc):
|
||||||
|
return jsonify({"error": "Method not allowed"}), 405
|
||||||
6
backend/app/extensions.py
Normal file
6
backend/app/extensions.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Shared Flask extension instances."""
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""SQLAlchemy models."""
|
||||||
|
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||||
|
from backend.app.models.user import User, UserRole
|
||||||
|
|
||||||
|
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus"]
|
||||||
39
backend/app/models/engagement.py
Normal file
39
backend/app/models/engagement.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Engagement model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementStatus(str, enum.Enum):
|
||||||
|
PLANNED = "planned"
|
||||||
|
ACTIVE = "active"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class Engagement(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "engagements"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
start_date = db.Column(db.Date, nullable=False)
|
||||||
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
status = db.Column(
|
||||||
|
db.Enum(EngagementStatus, name="engagement_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=EngagementStatus.PLANNED,
|
||||||
|
)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
created_by_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by = db.relationship("User", backref="engagements", lazy="joined")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Engagement {self.id} {self.name!r}>"
|
||||||
28
backend/app/models/user.py
Normal file
28
backend/app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""User model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
REDTEAM = "redteam"
|
||||||
|
SOC = "soc"
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.Enum(UserRole, name="user_role"), nullable=False)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.username} ({self.role.value})>"
|
||||||
34
backend/app/serializers.py
Normal file
34
backend/app/serializers.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""JSON serializers for API responses."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.app.models import Engagement, User
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_user(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role.value,
|
||||||
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_user_brief(user: User) -> dict[str, Any]:
|
||||||
|
return {"id": user.id, "username": user.username}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": engagement.id,
|
||||||
|
"name": engagement.name,
|
||||||
|
"description": engagement.description,
|
||||||
|
"start_date": engagement.start_date.isoformat() if engagement.start_date else None,
|
||||||
|
"end_date": engagement.end_date.isoformat() if engagement.end_date else None,
|
||||||
|
"status": engagement.status.value,
|
||||||
|
"created_at": engagement.created_at.isoformat() if engagement.created_at else None,
|
||||||
|
"created_by": serialize_user_brief(engagement.created_by) # type: ignore[arg-type]
|
||||||
|
if engagement.created_by
|
||||||
|
else None,
|
||||||
|
}
|
||||||
1
backend/migrations/README
Normal file
1
backend/migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration for Flask-Migrate / Alembic.
|
||||||
40
backend/migrations/alembic.ini
Normal file
40
backend/migrations/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Alembic config. Most settings are injected at runtime by Flask-Migrate.
|
||||||
|
[alembic]
|
||||||
|
script_location = %(here)s
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
73
backend/migrations/env.py
Normal file
73
backend/migrations/env.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Alembic environment, wired to Flask-Migrate."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger("alembic.env")
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# Flask-SQLAlchemy < 3.x
|
||||||
|
return current_app.extensions["migrate"].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# Flask-SQLAlchemy >= 3.x
|
||||||
|
return current_app.extensions["migrate"].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url() -> str:
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||||
|
target_db = current_app.extensions["migrate"].db
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, "metadatas"):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
def process_revision_directives(context, revision, directives): # noqa: ANN001
|
||||||
|
if getattr(config.cmd_opts, "autogenerate", False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info("No changes in schema detected.")
|
||||||
|
|
||||||
|
conf_args = current_app.extensions["migrate"].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
60
backend/migrations/versions/0001_initial_schema.py
Normal file
60
backend/migrations/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""initial schema: users + engagements
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-26 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.Enum("admin", "redteam", "soc", name="user_role"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint("username", name="uq_users_username"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"engagements",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("planned", "active", "closed", name="engagement_status"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("created_by_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_id"], ["users.id"], ondelete="RESTRICT",
|
||||||
|
name="fk_engagements_created_by_id_users",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("engagements")
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
|
sa.Enum(name="engagement_status").drop(op.get_bind(), checkfirst=True)
|
||||||
|
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)
|
||||||
28
backend/pyproject.toml
Normal file
28
backend/pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[project]
|
||||||
|
name = "mimic-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Mimic BAS backend (Flask API)"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = ["migrations/versions"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP", "SIM"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = false
|
||||||
|
ignore_missing_imports = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
no_implicit_optional = true
|
||||||
|
exclude = ["migrations/", "tests/"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra"
|
||||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
PyJWT==2.9.0
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
pytest==8.3.3
|
||||||
|
ruff==0.6.9
|
||||||
|
mypy==1.11.2
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
92
backend/tests/conftest.py
Normal file
92
backend/tests/conftest.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Shared pytest fixtures."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.config import TestConfig
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app() -> Generator[Flask, None, None]:
|
||||||
|
application = create_app(TestConfig())
|
||||||
|
with application.app_context():
|
||||||
|
db.create_all()
|
||||||
|
yield application
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(app: Flask) -> FlaskClient:
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_user(app: Flask) -> User:
|
||||||
|
user = User(
|
||||||
|
username="admin1",
|
||||||
|
password_hash=hash_password("adminpass1"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def redteam_user(app: Flask) -> User:
|
||||||
|
user = User(
|
||||||
|
username="redteam1",
|
||||||
|
password_hash=hash_password("redteampass1"),
|
||||||
|
role=UserRole.REDTEAM,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def soc_user(app: Flask) -> User:
|
||||||
|
user = User(
|
||||||
|
username="soc1",
|
||||||
|
password_hash=hash_password("socpass1"),
|
||||||
|
role=UserRole.SOC,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: FlaskClient, username: str, password: str) -> str:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": username, "password": password}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return resp.get_json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_token(client: FlaskClient, admin_user: User) -> str:
|
||||||
|
return _login(client, "admin1", "adminpass1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def redteam_token(client: FlaskClient, redteam_user: User) -> str:
|
||||||
|
return _login(client, "redteam1", "redteampass1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def soc_token(client: FlaskClient, soc_user: User) -> str:
|
||||||
|
return _login(client, "soc1", "socpass1")
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
41
backend/tests/test_app.py
Normal file
41
backend/tests/test_app.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""App-level tests: SPA fallback, health, error shapes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_api_path_returns_json_404(client: FlaskClient) -> None:
|
||||||
|
"""SPA fallback must not shadow unknown /api/* routes with index.html."""
|
||||||
|
resp = client.get("/api/nonexistent")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.is_json, f"expected JSON, got Content-Type={resp.content_type}"
|
||||||
|
assert resp.get_json() == {"error": "Not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_nested_api_path_returns_json_404(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/foo/bar/baz")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.is_json
|
||||||
|
assert resp.get_json() == {"error": "Not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_method_on_api_returns_json(client: FlaskClient) -> None:
|
||||||
|
# PUT is not defined on /api/auth/login — should stay JSON, not HTML.
|
||||||
|
resp = client.put("/api/auth/login")
|
||||||
|
assert resp.status_code in (404, 405)
|
||||||
|
assert resp.is_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_without_built_frontend_returns_json(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Tests run with no built frontend → factory falls back to a JSON status payload.
|
||||||
|
assert resp.is_json
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
93
backend/tests/test_auth.py
Normal file
93
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Auth endpoint tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success(client: FlaskClient, admin_user: User) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin1", "password": "adminpass1"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "access_token" in body and body["access_token"]
|
||||||
|
assert body["user"] == {"id": admin_user.id, "username": "admin1", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password(client: FlaskClient, admin_user: User) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin1", "password": "wrong"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json() == {"error": "Invalid credentials"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_user(client: FlaskClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": "ghost", "password": "anything!!"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
# Generic message — must not leak whether username exists.
|
||||||
|
assert resp.get_json() == {"error": "Invalid credentials"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_missing_fields(client: FlaskClient) -> None:
|
||||||
|
resp = client.post("/api/auth/login", json={})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_requires_token(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["username"] == "admin1"
|
||||||
|
assert body["role"] == "admin"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_with_invalid_token(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/auth/me", headers={"Authorization": "Bearer not.a.jwt"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_with_expired_token(
|
||||||
|
app: Flask, client: FlaskClient, admin_user: User
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(UTC) - timedelta(minutes=120)
|
||||||
|
payload = {
|
||||||
|
"sub": str(admin_user.id),
|
||||||
|
"role": "admin",
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(minutes=1)).timestamp()),
|
||||||
|
}
|
||||||
|
token = jwt.encode(
|
||||||
|
payload, app.config["JWT_SECRET"], algorithm=app.config["JWT_ALGORITHM"]
|
||||||
|
)
|
||||||
|
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json() == {"error": "Token expired"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_ok_with_token(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/logout", headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_without_token_is_401(client: FlaskClient) -> None:
|
||||||
|
resp = client.post("/api/auth/logout")
|
||||||
|
assert resp.status_code == 401
|
||||||
47
backend/tests/test_cli_create_admin.py
Normal file
47
backend/tests/test_cli_create_admin.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""CLI tests for `flask create-admin`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_success(app: Flask) -> None:
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin", "alice", "p4ssw0rd"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "created" in result.output.lower()
|
||||||
|
user = User.query.filter_by(username="alice").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.role == UserRole.ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_duplicate_username(app: Flask) -> None:
|
||||||
|
existing = User(
|
||||||
|
username="bob", password_hash=hash_password("originalpw"), role=UserRole.ADMIN
|
||||||
|
)
|
||||||
|
db.session.add(existing)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin", "bob", "anotherpw1"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "exists" in (result.output + (result.stderr_bytes.decode() if result.stderr_bytes else "")).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_short_password(app: Flask) -> None:
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin", "charlie", "abc"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
combined = (result.output + (result.stderr_bytes.decode() if result.stderr_bytes else "")).lower()
|
||||||
|
assert "8 characters" in combined
|
||||||
|
assert User.query.filter_by(username="charlie").first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_missing_args(app: Flask) -> None:
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin"])
|
||||||
|
# Click's UsageError exits with code 2
|
||||||
|
assert result.exit_code != 0
|
||||||
281
backend/tests/test_engagements.py
Normal file
281
backend/tests/test_engagements.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Engagement endpoint tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def _create(
|
||||||
|
client: FlaskClient, token: str, **overrides: object
|
||||||
|
) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": "Op Alpha",
|
||||||
|
"description": "first engagement",
|
||||||
|
"start_date": "2026-06-01",
|
||||||
|
"end_date": "2026-06-10",
|
||||||
|
"status": "planned",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = client.post("/api/engagements", headers=_h(token), json=payload)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_as_redteam(
|
||||||
|
client: FlaskClient, redteam_user: User, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
body = _create(client, redteam_token)
|
||||||
|
assert body["name"] == "Op Alpha"
|
||||||
|
assert body["status"] == "planned"
|
||||||
|
assert body["created_by"] == {"id": redteam_user.id, "username": "redteam1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_as_admin(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
body = _create(client, admin_token, name="Op Admin")
|
||||||
|
assert body["created_by"]["username"] == "admin1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_soc_forbidden(
|
||||||
|
client: FlaskClient, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"name": "x", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_unauth(client: FlaskClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements", json={"name": "x", "start_date": "2026-06-01"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_missing_name(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_bad_date(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "bad", "start_date": "not-a-date"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_end_before_start(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={
|
||||||
|
"name": "bad",
|
||||||
|
"start_date": "2026-06-10",
|
||||||
|
"end_date": "2026-06-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_bad_status(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "bad", "start_date": "2026-06-01", "status": "wat"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_default_status_planned(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "Op default", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["status"] == "planned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_engagements_all_roles_can_read(
|
||||||
|
client: FlaskClient,
|
||||||
|
redteam_token: str,
|
||||||
|
soc_token: str,
|
||||||
|
admin_token: str,
|
||||||
|
) -> None:
|
||||||
|
_create(client, redteam_token)
|
||||||
|
for token in (redteam_token, soc_token, admin_token):
|
||||||
|
resp = client.get("/api/engagements", headers=_h(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert isinstance(body, list)
|
||||||
|
assert len(body) >= 1
|
||||||
|
assert body[0]["created_by"] == {
|
||||||
|
"id": body[0]["created_by"]["id"],
|
||||||
|
"username": body[0]["created_by"]["username"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.get("/api/engagements/9999", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_engagement_ok(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == created["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"status": "active", "description": "now in progress"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["description"] == "now in progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_admin(
|
||||||
|
client: FlaskClient, redteam_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "Op renamed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["name"] == "Op renamed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_bad_status(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"status": "wat"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_soc_forbidden(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"status": "closed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_end_before_start(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token, start_date="2026-06-01")
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"end_date": "2026-05-30"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_clear_end_date(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token, end_date="2026-06-30")
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"end_date": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["end_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_empty_name_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": " "},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/engagements/9999", headers=_h(redteam_token), json={"name": "x"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_admin(
|
||||||
|
client: FlaskClient, redteam_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(admin_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_soc_forbidden(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(soc_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.delete("/api/engagements/9999", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
201
backend/tests/test_users.py
Normal file
201
backend/tests/test_users.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""User management endpoint tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_admin_only(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get("/api/users", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert isinstance(body, list)
|
||||||
|
assert any(u["username"] == "admin1" for u in body)
|
||||||
|
assert all("password_hash" not in u for u in body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_forbidden_for_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get("/api/users", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_forbidden_for_soc(client: FlaskClient, soc_token: str) -> None:
|
||||||
|
resp = client.get("/api/users", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_unauth(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/users")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_success(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "newbie", "password": "longenough1", "role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["username"] == "newbie"
|
||||||
|
assert body["role"] == "redteam"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_duplicate_username(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "admin1", "password": "longenough1", "role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "exists" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_short_password(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "short", "password": "abc", "role": "soc"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "8 characters" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_invalid_role(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "x", "password": "longenough1", "role": "godmode"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_forbidden_for_non_admin(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"username": "x", "password": "longenough1", "role": "soc"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_change_role(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["role"] == "redteam"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_change_password(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"password": "anotherone1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# New password should now allow login.
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login", json={"username": "soc1", "password": "anotherone1"}
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_short_password(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"password": "no"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_404(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/users/9999", headers=_h(admin_token), json={"role": "soc"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_forbidden_for_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}", headers=_h(redteam_token), json={"role": "admin"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_success(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.delete(f"/api/users/{soc_user.id}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_last_admin_blocked(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.delete(f"/api/users/{admin_user.id}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "last admin" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_admin_when_other_admin_exists(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
other = User(
|
||||||
|
username="admin2",
|
||||||
|
password_hash=hash_password("adminpass2"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
db.session.add(other)
|
||||||
|
db.session.commit()
|
||||||
|
other_id = other.id
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/users/{other_id}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_demote_last_admin_blocked(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{admin_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_404(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.delete("/api/users/9999", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_forbidden_for_soc(
|
||||||
|
client: FlaskClient, soc_token: str, redteam_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.delete(f"/api/users/{redteam_user.id}", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
30
docker/Dockerfile
Normal file
30
docker/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Stage 1: build front
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: python runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/requirements.txt ./backend/
|
||||||
|
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./backend/app/static
|
||||||
|
|
||||||
|
ENV FLASK_APP=backend.app:create_app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
# Variables surchargeables au `docker run` :
|
||||||
|
ENV MIMIC_PORT=5000
|
||||||
|
ENV MIMIC_DB_PATH=/data/mimic.sqlite
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Entrypoint : applique les migrations Alembic puis lance Flask
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
4
docker/entrypoint.sh
Executable file
4
docker/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
flask db upgrade
|
||||||
|
exec flask run --host=0.0.0.0 --port="${MIMIC_PORT:-5000}"
|
||||||
171
e2e/fixtures/api.ts
Normal file
171
e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Thin axios client used by tests to seed/teardown users and engagements
|
||||||
|
* without going through the UI. The bootstrap admin is created out-of-band
|
||||||
|
* (via `make create-admin`) and logs in once to provision per-suite users.
|
||||||
|
*/
|
||||||
|
import axios, { AxiosInstance, isAxiosError } from 'axios';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'redteam' | 'soc';
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Engagement {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
status: 'planned' | 'active' | 'closed';
|
||||||
|
created_at: string | null;
|
||||||
|
created_by: { id: number; username: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Role = User['role'];
|
||||||
|
|
||||||
|
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||||
|
const BOOTSTRAP_ADMIN_USER = process.env.MIMIC_BOOTSTRAP_USER ?? 'root';
|
||||||
|
const BOOTSTRAP_ADMIN_PASS = process.env.MIMIC_BOOTSTRAP_PASS ?? 'rootpass8';
|
||||||
|
|
||||||
|
export function makeClient(token?: string): AxiosInstance {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: `${BASE_URL}/api`,
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
validateStatus: () => true, // tests assert on status themselves
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; user: User }> {
|
||||||
|
const client = makeClient();
|
||||||
|
const r = await client.post('/auth/login', { username, password });
|
||||||
|
if (r.status !== 200) {
|
||||||
|
throw new Error(`login(${username}) failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return { token: r.data.access_token as string, user: r.data.user as User };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminToken(): Promise<string> {
|
||||||
|
const { token } = await login(BOOTSTRAP_ADMIN_USER, BOOTSTRAP_ADMIN_PASS);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent helper: ensures a user with the given username/role exists and
|
||||||
|
* has the requested password. Returns the user record.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - try login: if it succeeds, we're done.
|
||||||
|
* - else: as admin, list users; if username found, PATCH password+role; else POST.
|
||||||
|
*/
|
||||||
|
export async function ensureUser(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
role: Role,
|
||||||
|
): Promise<User> {
|
||||||
|
try {
|
||||||
|
const { user } = await login(username, password);
|
||||||
|
if (user.role !== role) {
|
||||||
|
const admin = await adminToken();
|
||||||
|
const client = makeClient(admin);
|
||||||
|
const r = await client.patch(`/users/${user.id}`, { role });
|
||||||
|
if (r.status !== 200) throw new Error(`patch role: ${r.status}`);
|
||||||
|
return r.data as User;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
// fall through to admin path
|
||||||
|
}
|
||||||
|
const admin = await adminToken();
|
||||||
|
const client = makeClient(admin);
|
||||||
|
const list = await client.get('/users');
|
||||||
|
if (list.status !== 200) {
|
||||||
|
throw new Error(`list users failed: ${list.status} ${JSON.stringify(list.data)}`);
|
||||||
|
}
|
||||||
|
const existing = (list.data as User[]).find((u) => u.username === username);
|
||||||
|
if (existing) {
|
||||||
|
const r = await client.patch(`/users/${existing.id}`, { password, role });
|
||||||
|
if (r.status !== 200) {
|
||||||
|
throw new Error(`patch user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return r.data as User;
|
||||||
|
}
|
||||||
|
const r = await client.post('/users', { username, password, role });
|
||||||
|
if (r.status !== 201) {
|
||||||
|
throw new Error(`create user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return r.data as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserByUsername(token: string, username: string): Promise<void> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const list = await client.get('/users');
|
||||||
|
if (list.status !== 200) return;
|
||||||
|
const u = (list.data as User[]).find((x) => x.username === username);
|
||||||
|
if (!u) return;
|
||||||
|
await client.delete(`/users/${u.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEngagement(
|
||||||
|
token: string,
|
||||||
|
payload: Partial<Pick<Engagement, 'name' | 'description' | 'start_date' | 'end_date' | 'status'>>,
|
||||||
|
): Promise<Engagement> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const body = {
|
||||||
|
name: payload.name ?? 'Test Engagement',
|
||||||
|
description: payload.description,
|
||||||
|
start_date: payload.start_date ?? '2026-01-01',
|
||||||
|
end_date: payload.end_date,
|
||||||
|
status: payload.status ?? 'planned',
|
||||||
|
};
|
||||||
|
const r = await client.post('/engagements', body);
|
||||||
|
if (r.status !== 201) {
|
||||||
|
throw new Error(`create engagement failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return r.data as Engagement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEngagement(token: string, id: number): Promise<void> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
await client.delete(`/engagements/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEngagements(token: string): Promise<Engagement[]> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const r = await client.get('/engagements');
|
||||||
|
if (r.status !== 200) {
|
||||||
|
throw new Error(`list engagements failed: ${r.status}`);
|
||||||
|
}
|
||||||
|
return r.data as Engagement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllEngagements(token: string): Promise<void> {
|
||||||
|
const items = await listEngagements(token);
|
||||||
|
await Promise.all(items.map((e) => deleteEngagement(token, e.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForHealth(timeoutMs = 30_000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
const client = makeClient();
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await client.get('/health');
|
||||||
|
if (r.status === 200) return;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`backend not healthy after ${timeoutMs}ms: ${
|
||||||
|
isAxiosError(lastErr) ? lastErr.message : String(lastErr)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE = BASE_URL;
|
||||||
67
e2e/fixtures/auth.ts
Normal file
67
e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Playwright auth helpers: log in via the API once per role, hydrate
|
||||||
|
* localStorage so subsequent page.goto() lands inside the SPA already
|
||||||
|
* authenticated.
|
||||||
|
*
|
||||||
|
* Avoids the per-test cost of driving the LoginPage form just to land on
|
||||||
|
* /engagements. The actual UI login flow IS exercised in us2-login.spec.ts.
|
||||||
|
*/
|
||||||
|
import { type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
import { BASE, login, type Role, type User } from './api';
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject token into localStorage so the SPA's bootstrap hook picks it up
|
||||||
|
* as if the user had already logged in. The frontend stores the JWT under
|
||||||
|
* `mimic.token` (see frontend/src/api/client.ts).
|
||||||
|
*/
|
||||||
|
export async function seedTokenInStorage(
|
||||||
|
context: BrowserContext,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await context.addInitScript((t) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('mimic.token', t);
|
||||||
|
} catch {
|
||||||
|
/* storage might not exist on about:blank — harmless */
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuthStorage(context: BrowserContext): Promise<void> {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem('mimic.token');
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in as the given role and return both the API session and a helper
|
||||||
|
* that prepares a Page with the auth token already seeded.
|
||||||
|
*/
|
||||||
|
export async function loginAs(
|
||||||
|
context: BrowserContext,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<Session> {
|
||||||
|
const session = await login(username, password);
|
||||||
|
await seedTokenInStorage(context, session.token);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: navigate to a path on a page that's already had its
|
||||||
|
* context seeded with a token.
|
||||||
|
*/
|
||||||
|
export async function gotoApp(page: Page, path = '/engagements'): Promise<void> {
|
||||||
|
await page.goto(`${BASE}${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Role };
|
||||||
470
e2e/package-lock.json
generated
Normal file
470
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
{
|
||||||
|
"name": "mimic-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mimic-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||||
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
e2e/package.json
Normal file
17
e2e/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "mimic-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:headed": "playwright test --headed",
|
||||||
|
"test:report": "playwright show-report"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
e2e/playwright.config.ts
Normal file
40
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-container e2e setup — Mimic backend serves both /api/* and the built SPA.
|
||||||
|
*
|
||||||
|
* Sequence (run manually before `npx playwright test`):
|
||||||
|
* 1. make build && make start
|
||||||
|
* 2. make create-admin USER=root PASS=rootpass8
|
||||||
|
* 3. ensure `curl /api/health` is 200
|
||||||
|
*
|
||||||
|
* Tests run **serially** because all state lives in a single SQLite file in the
|
||||||
|
* shared container. RBAC tests need stable user fixtures across spec files.
|
||||||
|
*/
|
||||||
|
const baseURL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
actionTimeout: 10_000,
|
||||||
|
navigationTimeout: 15_000,
|
||||||
|
extraHTTPHeaders: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
109
e2e/tests/us1-bootstrap-admin.spec.ts
Normal file
109
e2e/tests/us1-bootstrap-admin.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* US-1 — bootstrap the first admin via `flask create-admin`.
|
||||||
|
*
|
||||||
|
* The `make create-admin` target wraps `docker exec mimic flask create-admin …`.
|
||||||
|
* These tests exercise the CLI directly through `docker exec` (or whatever
|
||||||
|
* runtime the harness exposes via the wrapper), and a follow-up API login to
|
||||||
|
* confirm the row was created with role=admin and an argon2 hash that verifies.
|
||||||
|
*
|
||||||
|
* NOTE: the bootstrap admin (`root` / `rootpass8`) is already created out-of-band
|
||||||
|
* before the Playwright suite starts. Test usernames here are scoped to `us1-*`
|
||||||
|
* and cleaned up via API in afterAll.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { adminToken, deleteUserByUsername, login, makeClient } from '../fixtures/api';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
|
||||||
|
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
|
||||||
|
|
||||||
|
function runCreateAdmin(user: string, pass: string): {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
status: number;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const stdout = execSync(`${RUNTIME} exec ${CONTAINER} flask create-admin ${user} ${pass}`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
return { stdout, stderr: '', status: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||||
|
return {
|
||||||
|
stdout: typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '',
|
||||||
|
stderr: typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '',
|
||||||
|
status: err.status ?? -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-1 — bootstrap first admin', () => {
|
||||||
|
const probeUser = 'us1-probe-admin';
|
||||||
|
const dupUser = 'us1-dup-admin';
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const token = await adminToken();
|
||||||
|
for (const u of [probeUser, dupUser]) {
|
||||||
|
await deleteUserByUsername(token, u);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort cleanup */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-1.1 — create-admin creates a user with role=admin and an argon2 hash that authenticates', async () => {
|
||||||
|
const probePass = 'probepass123';
|
||||||
|
const result = runCreateAdmin(probeUser, probePass);
|
||||||
|
expect(result.status, `CLI failed: ${result.stderr || result.stdout}`).toBe(0);
|
||||||
|
expect(result.stdout).toMatch(/created/i);
|
||||||
|
|
||||||
|
// Roundtrip: the resulting credentials must log in as role=admin.
|
||||||
|
const { user } = await login(probeUser, probePass);
|
||||||
|
expect(user.username).toBe(probeUser);
|
||||||
|
expect(user.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-1.2 — fails cleanly when username already exists', async () => {
|
||||||
|
// Seed once, then call again.
|
||||||
|
runCreateAdmin(dupUser, 'firstpass8');
|
||||||
|
const second = runCreateAdmin(dupUser, 'secondpass8');
|
||||||
|
expect(second.status).not.toBe(0);
|
||||||
|
const combined = (second.stderr + second.stdout).toLowerCase();
|
||||||
|
expect(combined).toMatch(/exists|already|error/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-1.3 — refuses passwords shorter than 8 characters', async () => {
|
||||||
|
const result = runCreateAdmin('us1-shortpass-user', 'short7');
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
const combined = (result.stderr + result.stdout).toLowerCase();
|
||||||
|
expect(combined).toMatch(/8|length|password/);
|
||||||
|
|
||||||
|
// Make sure the short-password attempt did NOT create a row.
|
||||||
|
const probe = await makeClient().post('/auth/login', {
|
||||||
|
username: 'us1-shortpass-user',
|
||||||
|
password: 'short7',
|
||||||
|
});
|
||||||
|
expect(probe.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-1.4 — make create-admin wraps `docker exec mimic flask create-admin` (the bootstrap admin proves it ran via that path)', async () => {
|
||||||
|
// The harness step `make create-admin USER=root PASS=rootpass8` is what
|
||||||
|
// got the suite to the point where adminToken() works. If that call had
|
||||||
|
// been broken, this assertion would have already failed when seeding.
|
||||||
|
const token = await adminToken();
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
// Defence-in-depth: assert the Makefile target literally invokes
|
||||||
|
// `docker exec … flask create-admin`. This is a contract check.
|
||||||
|
const makefilePath = resolve(__dirname, '../..', 'Makefile');
|
||||||
|
const content = readFileSync(makefilePath, 'utf8');
|
||||||
|
expect(content).toMatch(/docker exec .+ flask create-admin/);
|
||||||
|
});
|
||||||
|
});
|
||||||
158
e2e/tests/us2-login.spec.ts
Normal file
158
e2e/tests/us2-login.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* US-2 — login / logout / session expiry.
|
||||||
|
*
|
||||||
|
* Covers AC-2.1 through AC-2.6. Mix of pure-API assertions (token shape +
|
||||||
|
* generic 401) and full UI flow (form submit, redirect, expired-token toast).
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { adminToken, ensureUser, deleteUserByUsername, makeClient } from '../fixtures/api';
|
||||||
|
import { clearAuthStorage, seedTokenInStorage } from '../fixtures/auth';
|
||||||
|
|
||||||
|
const USERNAME = 'us2-loginuser';
|
||||||
|
const PASSWORD = 'us2-pass8chars';
|
||||||
|
|
||||||
|
test.describe('US-2 — auth flow', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(USERNAME, PASSWORD, 'redteam');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const token = await adminToken();
|
||||||
|
await deleteUserByUsername(token, USERNAME);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-2.1 — POST /api/auth/login returns access_token + user payload', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const r = await client.post('/auth/login', {
|
||||||
|
username: USERNAME,
|
||||||
|
password: PASSWORD,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(typeof r.data.access_token).toBe('string');
|
||||||
|
expect(r.data.access_token.length).toBeGreaterThan(20);
|
||||||
|
expect(r.data.user).toMatchObject({
|
||||||
|
username: USERNAME,
|
||||||
|
role: 'redteam',
|
||||||
|
});
|
||||||
|
expect(typeof r.data.user.id).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-2.2 — bad credentials return 401 with generic error (no username/password leak)', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
|
||||||
|
const wrongPass = await client.post('/auth/login', {
|
||||||
|
username: USERNAME,
|
||||||
|
password: 'nope-not-the-password',
|
||||||
|
});
|
||||||
|
expect(wrongPass.status).toBe(401);
|
||||||
|
expect(wrongPass.data.error).toBe('Invalid credentials');
|
||||||
|
|
||||||
|
const noUser = await client.post('/auth/login', {
|
||||||
|
username: 'does-not-exist-anywhere',
|
||||||
|
password: 'whatever12345',
|
||||||
|
});
|
||||||
|
expect(noUser.status).toBe(401);
|
||||||
|
expect(noUser.data.error).toBe('Invalid credentials');
|
||||||
|
|
||||||
|
// Critical: same message in both cases — no enumeration of users.
|
||||||
|
expect(noUser.data.error).toBe(wrongPass.data.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-2.3 — logout: client-side token purge (UI clears localStorage)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', USERNAME);
|
||||||
|
await page.fill('input[name="password"]', PASSWORD);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL(/\/engagements\b/);
|
||||||
|
|
||||||
|
// Token must now exist in localStorage.
|
||||||
|
const before = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
|
||||||
|
expect(before).toBeTruthy();
|
||||||
|
|
||||||
|
// Click "Sign out" (Layout topbar).
|
||||||
|
await page.getByRole('button', { name: /sign out/i }).click();
|
||||||
|
await page.waitForURL(/\/login\b/);
|
||||||
|
|
||||||
|
const after = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
|
||||||
|
expect(after).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-2.4 — /login form: success → /engagements, failure → visible error message', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Failure path first.
|
||||||
|
await page.fill('input[name="username"]', USERNAME);
|
||||||
|
await page.fill('input[name="password"]', 'wrongpassword');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
const errorLocator = page.getByTestId('login-error');
|
||||||
|
await expect(errorLocator).toBeVisible();
|
||||||
|
await expect(errorLocator).toHaveText(/invalid credentials/i);
|
||||||
|
|
||||||
|
// Now success.
|
||||||
|
await page.fill('input[name="password"]', PASSWORD);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL(/\/engagements\b/);
|
||||||
|
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-2.5 — navigating to /engagements without a token redirects to /login', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await clearAuthStorage(context);
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.goto('/engagements');
|
||||||
|
await page.waitForURL(/\/login\b/);
|
||||||
|
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-2.6 — 401 from API purges token + redirects to /login with "Session expirée" toast', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
// Seed a clearly-invalid token so the very first API call (from /me on
|
||||||
|
// app bootstrap) returns 401, tripping the axios interceptor.
|
||||||
|
await seedTokenInStorage(context, 'totally.invalid.jwt');
|
||||||
|
|
||||||
|
// Race-safe toast capture: poll the DOM while React renders + the
|
||||||
|
// interceptor's window.location.assign('/login') tears the page down.
|
||||||
|
let sawToast = false;
|
||||||
|
const stopWatching = page
|
||||||
|
.waitForSelector('[data-testid="toast"]:has-text("Session expir")', {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 8_000,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
sawToast = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* didn't catch the toast in time — assertion below will flag */
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/engagements');
|
||||||
|
|
||||||
|
// Interceptor should boot us to /login.
|
||||||
|
await page.waitForURL(/\/login\b/, { timeout: 10_000 });
|
||||||
|
|
||||||
|
// Storage must be purged. Use waitForFunction to dodge the navigation race.
|
||||||
|
await page.waitForFunction(() => window.localStorage.getItem('mimic.token') === null, {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await stopWatching;
|
||||||
|
expect(
|
||||||
|
sawToast,
|
||||||
|
'expected "Session expirée" toast to be visible at some point during the 401 redirect',
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
e2e/tests/us3-users-admin.spec.ts
Normal file
235
e2e/tests/us3-users-admin.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* US-3 — admin manages user accounts.
|
||||||
|
* Covers AC-3.1 → AC-3.7. RBAC matrix exercised at the API for each verb,
|
||||||
|
* and the /admin/users page is exercised in the UI for create + role-gate.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
adminToken,
|
||||||
|
deleteUserByUsername,
|
||||||
|
ensureUser,
|
||||||
|
login,
|
||||||
|
makeClient,
|
||||||
|
} from '../fixtures/api';
|
||||||
|
import { seedTokenInStorage } from '../fixtures/auth';
|
||||||
|
|
||||||
|
const REDTEAM_USER = 'us3-redteam';
|
||||||
|
const SOC_USER = 'us3-soc';
|
||||||
|
const PASS = 'us3-pass-strong';
|
||||||
|
|
||||||
|
test.describe('US-3 — user admin', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||||
|
await ensureUser(SOC_USER, PASS, 'soc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const token = await adminToken();
|
||||||
|
for (const u of [
|
||||||
|
REDTEAM_USER,
|
||||||
|
SOC_USER,
|
||||||
|
'us3-created-via-api',
|
||||||
|
'us3-patched',
|
||||||
|
'us3-deleted',
|
||||||
|
'us3-ui-newuser',
|
||||||
|
]) {
|
||||||
|
await deleteUserByUsername(token, u);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.1 — GET /api/users returns list for admin', async () => {
|
||||||
|
const token = await adminToken();
|
||||||
|
const client = makeClient(token);
|
||||||
|
const r = await client.get('/users');
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(Array.isArray(r.data)).toBe(true);
|
||||||
|
const sample = (r.data as Array<Record<string, unknown>>).find(
|
||||||
|
(u) => u.username === REDTEAM_USER,
|
||||||
|
);
|
||||||
|
expect(sample).toBeTruthy();
|
||||||
|
expect(sample).toMatchObject({ role: 'redteam' });
|
||||||
|
expect(sample!.password_hash).toBeUndefined();
|
||||||
|
expect(sample!.id).toBeDefined();
|
||||||
|
expect(sample!.created_at).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.2 — POST /api/users creates user (201); 400 on duplicate or short password', async () => {
|
||||||
|
const token = await adminToken();
|
||||||
|
const client = makeClient(token);
|
||||||
|
|
||||||
|
const created = await client.post('/users', {
|
||||||
|
username: 'us3-created-via-api',
|
||||||
|
password: 'longenough8',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
expect(created.data).toMatchObject({
|
||||||
|
username: 'us3-created-via-api',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
expect(created.data.password_hash).toBeUndefined();
|
||||||
|
|
||||||
|
const dup = await client.post('/users', {
|
||||||
|
username: 'us3-created-via-api',
|
||||||
|
password: 'longenough8',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
expect(dup.status).toBe(400);
|
||||||
|
|
||||||
|
const short = await client.post('/users', {
|
||||||
|
username: 'us3-shortpw-x',
|
||||||
|
password: 'short7',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
expect(short.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.3 — PATCH /api/users/<id> updates role and/or password', async () => {
|
||||||
|
const token = await adminToken();
|
||||||
|
const client = makeClient(token);
|
||||||
|
|
||||||
|
// Seed a fresh user.
|
||||||
|
const created = await client.post('/users', {
|
||||||
|
username: 'us3-patched',
|
||||||
|
password: 'initialpass8',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const id = created.data.id as number;
|
||||||
|
|
||||||
|
// PATCH role.
|
||||||
|
const r1 = await client.patch(`/users/${id}`, { role: 'redteam' });
|
||||||
|
expect(r1.status).toBe(200);
|
||||||
|
expect(r1.data.role).toBe('redteam');
|
||||||
|
|
||||||
|
// PATCH password — must be usable for login.
|
||||||
|
const r2 = await client.patch(`/users/${id}`, { password: 'newstrongpass8' });
|
||||||
|
expect(r2.status).toBe(200);
|
||||||
|
const { token: rotated } = await login('us3-patched', 'newstrongpass8');
|
||||||
|
expect(rotated).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.4 — DELETE /api/users/<id> returns 204; refuses last admin (409)', async () => {
|
||||||
|
const token = await adminToken();
|
||||||
|
const client = makeClient(token);
|
||||||
|
|
||||||
|
// Create a disposable redteam, delete it → 204.
|
||||||
|
const created = await client.post('/users', {
|
||||||
|
username: 'us3-deleted',
|
||||||
|
password: 'disposable8',
|
||||||
|
role: 'redteam',
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const id = created.data.id as number;
|
||||||
|
|
||||||
|
const del = await client.delete(`/users/${id}`);
|
||||||
|
expect(del.status).toBe(204);
|
||||||
|
|
||||||
|
// Verify gone.
|
||||||
|
const list = await client.get('/users');
|
||||||
|
const found = (list.data as Array<{ id: number }>).find((u) => u.id === id);
|
||||||
|
expect(found).toBeUndefined();
|
||||||
|
|
||||||
|
// Last-admin protection — list admins and try to delete the only one.
|
||||||
|
const all = await client.get('/users');
|
||||||
|
const admins = (all.data as Array<{ id: number; role: string }>).filter(
|
||||||
|
(u) => u.role === 'admin',
|
||||||
|
);
|
||||||
|
if (admins.length === 1) {
|
||||||
|
const r = await client.delete(`/users/${admins[0].id}`);
|
||||||
|
expect(r.status).toBe(409);
|
||||||
|
} else {
|
||||||
|
// If suite added extra admins, demote-then-delete protection still applies:
|
||||||
|
// we attempt a hypothetical demote of one admin (PATCH to redteam) and the
|
||||||
|
// last one must be refused.
|
||||||
|
// Iterate: keep deleting admins one by one until 1 remains, then assert 409.
|
||||||
|
// (Skipped in well-isolated runs because typical state = 1 admin.)
|
||||||
|
const ids = admins.map((a) => a.id);
|
||||||
|
while (ids.length > 1) {
|
||||||
|
const victim = ids.pop()!;
|
||||||
|
const r = await client.delete(`/users/${victim}`);
|
||||||
|
expect(r.status).toBe(204);
|
||||||
|
}
|
||||||
|
const finalId = ids[0];
|
||||||
|
const r = await client.delete(`/users/${finalId}`);
|
||||||
|
expect(r.status).toBe(409);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.5 — redteam and soc receive 403 on user-admin endpoints', async () => {
|
||||||
|
for (const role of ['redteam', 'soc'] as const) {
|
||||||
|
const username = role === 'redteam' ? REDTEAM_USER : SOC_USER;
|
||||||
|
const { token } = await login(username, PASS);
|
||||||
|
const client = makeClient(token);
|
||||||
|
|
||||||
|
const list = await client.get('/users');
|
||||||
|
expect(list.status, `${role} GET /users`).toBe(403);
|
||||||
|
|
||||||
|
const post = await client.post('/users', {
|
||||||
|
username: `${role}-attempt`,
|
||||||
|
password: 'whatever8x',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
expect(post.status, `${role} POST /users`).toBe(403);
|
||||||
|
|
||||||
|
const patch = await client.patch('/users/1', { role: 'soc' });
|
||||||
|
expect(patch.status, `${role} PATCH /users/1`).toBe(403);
|
||||||
|
|
||||||
|
const del = await client.delete('/users/999');
|
||||||
|
expect(del.status, `${role} DELETE /users/999`).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.6 — /admin/users page lists users + allows create + reset-password + delete', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const token = await adminToken();
|
||||||
|
await seedTokenInStorage(context, token);
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await expect(page.getByRole('heading', { name: /user accounts/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Create new user via the form.
|
||||||
|
const newName = 'us3-ui-newuser';
|
||||||
|
await page.fill('#new-username', newName);
|
||||||
|
await page.fill('#new-password', 'uistrongpw8');
|
||||||
|
await page.selectOption('#new-role', 'soc');
|
||||||
|
await page.getByRole('button', { name: /^create$/i }).click();
|
||||||
|
|
||||||
|
// Row appears.
|
||||||
|
const row = page.getByRole('row', { name: new RegExp(newName) });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Reset password flow opens a sub-form.
|
||||||
|
await row.getByRole('button', { name: /reset password/i }).click();
|
||||||
|
await page.fill(`input[id^="reset-"]`, 'rotatedpass8');
|
||||||
|
await page.getByRole('button', { name: /save password/i }).click();
|
||||||
|
await expect(page.getByTestId('toast').filter({ hasText: /password reset/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Delete row (confirm dialog).
|
||||||
|
page.once('dialog', (d) => d.accept());
|
||||||
|
await row.getByRole('button', { name: /^delete$/i }).click();
|
||||||
|
await expect(page.getByRole('row', { name: new RegExp(newName) })).toHaveCount(0, {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-3.7 — redteam/soc visiting /admin/users → redirected to /engagements + "Accès refusé" toast', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const { token } = await login(SOC_USER, PASS);
|
||||||
|
await seedTokenInStorage(context, token);
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.waitForURL(/\/engagements\b/, { timeout: 5_000 });
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('toast').filter({ hasText: /accès refusé/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
270
e2e/tests/us4-engagements.spec.ts
Normal file
270
e2e/tests/us4-engagements.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* US-4 — engagement CRUD + RBAC + UI surfaces.
|
||||||
|
* Covers AC-4.1 → AC-4.9.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
adminToken,
|
||||||
|
createEngagement,
|
||||||
|
deleteAllEngagements,
|
||||||
|
deleteEngagement,
|
||||||
|
deleteUserByUsername,
|
||||||
|
ensureUser,
|
||||||
|
listEngagements,
|
||||||
|
login,
|
||||||
|
makeClient,
|
||||||
|
} from '../fixtures/api';
|
||||||
|
import { seedTokenInStorage } from '../fixtures/auth';
|
||||||
|
|
||||||
|
const REDTEAM_USER = 'us4-redteam';
|
||||||
|
const SOC_USER = 'us4-soc';
|
||||||
|
const PASS = 'us4-pass-strong';
|
||||||
|
|
||||||
|
test.describe('US-4 — engagement CRUD', () => {
|
||||||
|
let redteamToken: string;
|
||||||
|
let socToken: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||||
|
await ensureUser(SOC_USER, PASS, 'soc');
|
||||||
|
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||||
|
socToken = (await login(SOC_USER, PASS)).token;
|
||||||
|
// Clean slate so AC-4.7 list assertions are predictable.
|
||||||
|
await deleteAllEngagements(await adminToken());
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const tok = await adminToken();
|
||||||
|
await deleteAllEngagements(tok);
|
||||||
|
for (const u of [REDTEAM_USER, SOC_USER]) {
|
||||||
|
await deleteUserByUsername(tok, u);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.1 — GET /api/engagements returns serialized list (created_by = {id, username})', async () => {
|
||||||
|
const seeded = await createEngagement(redteamToken, {
|
||||||
|
name: 'AC-4.1 sample',
|
||||||
|
start_date: '2026-02-01',
|
||||||
|
});
|
||||||
|
const items = await listEngagements(redteamToken);
|
||||||
|
const row = items.find((i) => i.id === seeded.id);
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
name: 'AC-4.1 sample',
|
||||||
|
status: 'planned',
|
||||||
|
start_date: '2026-02-01',
|
||||||
|
});
|
||||||
|
expect(row!.created_by).toMatchObject({ username: REDTEAM_USER });
|
||||||
|
expect(typeof row!.created_by!.id).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.2 — POST validates name/dates/status', async () => {
|
||||||
|
const client = makeClient(redteamToken);
|
||||||
|
|
||||||
|
const blankName = await client.post('/engagements', {
|
||||||
|
name: '',
|
||||||
|
start_date: '2026-03-01',
|
||||||
|
});
|
||||||
|
expect(blankName.status).toBe(400);
|
||||||
|
|
||||||
|
const noStart = await client.post('/engagements', { name: 'x' });
|
||||||
|
expect(noStart.status).toBe(400);
|
||||||
|
|
||||||
|
const badDate = await client.post('/engagements', {
|
||||||
|
name: 'x',
|
||||||
|
start_date: 'not-a-date',
|
||||||
|
});
|
||||||
|
expect(badDate.status).toBe(400);
|
||||||
|
|
||||||
|
const endBeforeStart = await client.post('/engagements', {
|
||||||
|
name: 'x',
|
||||||
|
start_date: '2026-04-10',
|
||||||
|
end_date: '2026-04-01',
|
||||||
|
});
|
||||||
|
expect(endBeforeStart.status).toBe(400);
|
||||||
|
|
||||||
|
const badStatus = await client.post('/engagements', {
|
||||||
|
name: 'x',
|
||||||
|
start_date: '2026-04-01',
|
||||||
|
status: 'frozen',
|
||||||
|
});
|
||||||
|
expect(badStatus.status).toBe(400);
|
||||||
|
|
||||||
|
const defaultStatus = await client.post('/engagements', {
|
||||||
|
name: 'AC-4.2 default-status',
|
||||||
|
start_date: '2026-04-01',
|
||||||
|
});
|
||||||
|
expect(defaultStatus.status).toBe(201);
|
||||||
|
expect(defaultStatus.data.status).toBe('planned');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.3 — GET /api/engagements/<id> returns 200 + object, 404 if unknown', async () => {
|
||||||
|
const seeded = await createEngagement(redteamToken, {
|
||||||
|
name: 'AC-4.3 sample',
|
||||||
|
start_date: '2026-05-01',
|
||||||
|
});
|
||||||
|
const client = makeClient(redteamToken);
|
||||||
|
const ok = await client.get(`/engagements/${seeded.id}`);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(ok.data.id).toBe(seeded.id);
|
||||||
|
|
||||||
|
const missing = await client.get('/engagements/999999');
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.4 — PATCH (admin/redteam) updates fields', async () => {
|
||||||
|
const seeded = await createEngagement(redteamToken, {
|
||||||
|
name: 'AC-4.4 orig',
|
||||||
|
start_date: '2026-06-01',
|
||||||
|
});
|
||||||
|
const client = makeClient(redteamToken);
|
||||||
|
const r = await client.patch(`/engagements/${seeded.id}`, {
|
||||||
|
name: 'AC-4.4 updated',
|
||||||
|
status: 'active',
|
||||||
|
end_date: '2026-06-15',
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.data).toMatchObject({
|
||||||
|
name: 'AC-4.4 updated',
|
||||||
|
status: 'active',
|
||||||
|
end_date: '2026-06-15',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.5 — DELETE (admin/redteam) returns 204', async () => {
|
||||||
|
const seeded = await createEngagement(redteamToken, {
|
||||||
|
name: 'AC-4.5 disposable',
|
||||||
|
start_date: '2026-07-01',
|
||||||
|
});
|
||||||
|
const client = makeClient(redteamToken);
|
||||||
|
const r = await client.delete(`/engagements/${seeded.id}`);
|
||||||
|
expect(r.status).toBe(204);
|
||||||
|
|
||||||
|
const after = await client.get(`/engagements/${seeded.id}`);
|
||||||
|
expect(after.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.6 — soc can read but not write (403 on POST/PATCH/DELETE)', async () => {
|
||||||
|
const socClient = makeClient(socToken);
|
||||||
|
const list = await socClient.get('/engagements');
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
|
||||||
|
const post = await socClient.post('/engagements', {
|
||||||
|
name: 'soc-blocked',
|
||||||
|
start_date: '2026-08-01',
|
||||||
|
});
|
||||||
|
expect(post.status).toBe(403);
|
||||||
|
|
||||||
|
// Seed via redteam to get a target id.
|
||||||
|
const target = await createEngagement(redteamToken, {
|
||||||
|
name: 'AC-4.6 target',
|
||||||
|
start_date: '2026-08-15',
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = await socClient.patch(`/engagements/${target.id}`, { name: 'soc-edit' });
|
||||||
|
expect(patch.status).toBe(403);
|
||||||
|
|
||||||
|
const del = await socClient.delete(`/engagements/${target.id}`);
|
||||||
|
expect(del.status).toBe(403);
|
||||||
|
|
||||||
|
// Clean up via redteam.
|
||||||
|
await deleteEngagement(redteamToken, target.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.7 — /engagements page lists rows with required columns + role-aware buttons', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
// Seed one row visible to the redteam user.
|
||||||
|
await createEngagement(redteamToken, {
|
||||||
|
name: 'UI list sample',
|
||||||
|
start_date: '2026-09-01',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto('/engagements');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
|
||||||
|
// Column headers
|
||||||
|
for (const h of ['Name', 'Status', 'Start', 'End', 'Created by']) {
|
||||||
|
await expect(page.getByRole('columnheader', { name: new RegExp(h, 'i') })).toBeVisible();
|
||||||
|
}
|
||||||
|
// The row + status badge + created_by visible
|
||||||
|
const row = page.getByRole('row', { name: /UI list sample/i });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
|
||||||
|
|
||||||
|
// Redteam sees the action buttons.
|
||||||
|
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible();
|
||||||
|
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
|
||||||
|
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Soc should NOT see write buttons.
|
||||||
|
await seedTokenInStorage(context, socToken);
|
||||||
|
await page.goto('/engagements');
|
||||||
|
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
|
||||||
|
await expect(rowAsSoc).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0);
|
||||||
|
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
|
||||||
|
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.8 — /engagements/new form: client validation + API error display', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
|
||||||
|
await page.goto('/engagements/new');
|
||||||
|
await expect(page.getByRole('heading', { name: /new engagement/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Submit empty → client-side errors visible.
|
||||||
|
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||||
|
await expect(page.getByText(/name is required/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/start date is required/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Fill bad date order → client validation flags end_date.
|
||||||
|
await page.fill('#eng-name', 'UI form test');
|
||||||
|
await page.fill('#eng-start', '2026-10-10');
|
||||||
|
await page.fill('#eng-end', '2026-10-01');
|
||||||
|
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||||
|
await expect(page.getByText(/end date must be on or after start date/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Fix dates → submit succeeds, redirects to detail.
|
||||||
|
await page.fill('#eng-end', '2026-10-20');
|
||||||
|
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||||
|
await page.waitForURL(/\/engagements\/\d+$/);
|
||||||
|
await expect(page.getByRole('heading', { name: /UI form test/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Edit path: navigate to /edit and tweak.
|
||||||
|
const detailUrl = page.url();
|
||||||
|
const id = Number(detailUrl.split('/').pop());
|
||||||
|
await page.goto(`/engagements/${id}/edit`);
|
||||||
|
await expect(page.getByRole('heading', { name: /edit engagement/i })).toBeVisible();
|
||||||
|
await page.fill('#eng-name', 'UI form test (edited)');
|
||||||
|
await page.getByRole('button', { name: /save changes/i }).click();
|
||||||
|
await page.waitForURL(new RegExp(`/engagements/${id}$`));
|
||||||
|
await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-4.9 — /engagements/<id> detail page shows Sprint 2 placeholder', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const seeded = await createEngagement(redteamToken, {
|
||||||
|
name: 'AC-4.9 detail target',
|
||||||
|
start_date: '2026-11-01',
|
||||||
|
description: 'A description for detail rendering.',
|
||||||
|
});
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${seeded.id}`);
|
||||||
|
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/simulations à venir au sprint 2/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
e2e/tests/us5-design.spec.ts
Normal file
161
e2e/tests/us5-design.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* US-5 — UI respects DESIGN.md tokens, layout doesn't break at 1280×720,
|
||||||
|
* and loading/error/empty states are wired.
|
||||||
|
*
|
||||||
|
* Pixel-perfect fidelity is NOT enforced — instead we assert that the
|
||||||
|
* tokenised classes from tailwind.config.ts are present and that the canvas
|
||||||
|
* has no horizontal overflow at the design viewport.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
adminToken,
|
||||||
|
deleteAllEngagements,
|
||||||
|
deleteUserByUsername,
|
||||||
|
ensureUser,
|
||||||
|
login,
|
||||||
|
} from '../fixtures/api';
|
||||||
|
import { seedTokenInStorage } from '../fixtures/auth';
|
||||||
|
|
||||||
|
const REDTEAM_USER = 'us5-redteam';
|
||||||
|
const PASS = 'us5-passw0rd';
|
||||||
|
|
||||||
|
test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
|
||||||
|
let redteamToken: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||||
|
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const tok = await adminToken();
|
||||||
|
await deleteAllEngagements(tok);
|
||||||
|
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-5.1 — DESIGN.md tokens applied (Inter font, brand palette, named utilities)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto('/engagements');
|
||||||
|
|
||||||
|
// Body font must resolve to a stack including "Inter".
|
||||||
|
const fontFamily = await page.evaluate(
|
||||||
|
() => window.getComputedStyle(document.body).fontFamily,
|
||||||
|
);
|
||||||
|
expect(fontFamily.toLowerCase()).toMatch(/inter/);
|
||||||
|
|
||||||
|
// Tailwind-compiled DESIGN palette: the primary chevron at the top-left of
|
||||||
|
// the nav uses `bg-primary` → rendered as rgb(2, 74, 216).
|
||||||
|
const chevronBg = await page
|
||||||
|
.locator('header a[aria-label="Mimic home"] span[aria-hidden]')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||||
|
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)');
|
||||||
|
|
||||||
|
// Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)).
|
||||||
|
const utilityBg = await page
|
||||||
|
.locator('div.bg-ink.text-ink-on')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||||
|
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)');
|
||||||
|
|
||||||
|
// Spot-check a few semantic class names live in the DOM (proves tokens are
|
||||||
|
// wired through tailwind.config.ts and not ad-hoc hex values).
|
||||||
|
await expect(page.locator('.btn-primary, .card-product, .max-w-page').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-5.2 — no horizontal overflow at 1280×720 across key pages', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
|
||||||
|
const routes = ['/engagements', '/engagements/new'];
|
||||||
|
for (const route of routes) {
|
||||||
|
await page.goto(route);
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(
|
||||||
|
overflow.scrollWidth,
|
||||||
|
`${route} should not horizontally overflow at 1280px`,
|
||||||
|
).toBeLessThanOrEqual(overflow.clientWidth + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-5.3a — empty state renders when engagements list is empty', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
const tok = await adminToken();
|
||||||
|
await deleteAllEngagements(tok);
|
||||||
|
await page.goto('/engagements');
|
||||||
|
await expect(page.getByRole('heading', { name: /no engagements yet/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-5.3b — loading state renders while engagements list is in-flight', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
|
||||||
|
// Stall the list endpoint so the LoadingState becomes visible.
|
||||||
|
const handler = async (route: import('@playwright/test').Route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (/\/api\/engagements(\?.*)?$/.test(url)) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await route.continue();
|
||||||
|
} catch {
|
||||||
|
/* route may have been torn down by the time we resume */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await page.route('**/api/engagements**', handler);
|
||||||
|
|
||||||
|
const navPromise = page.goto('/engagements');
|
||||||
|
await expect(page.getByText(/loading engagements/i)).toBeVisible({ timeout: 3_000 });
|
||||||
|
await navPromise;
|
||||||
|
await page.unroute('**/api/engagements**', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-5.3c — error state renders when the engagements list returns 500', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
|
||||||
|
const handler = async (route: import('@playwright/test').Route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (/\/api\/engagements(\?.*)?$/.test(url)) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: 'Forced failure' }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await route.continue();
|
||||||
|
} catch {
|
||||||
|
/* route teardown race — harmless */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await page.route('**/api/engagements**', handler);
|
||||||
|
|
||||||
|
await page.goto('/engagements');
|
||||||
|
await expect(page.getByTestId('error-state')).toBeVisible({ timeout: 8_000 });
|
||||||
|
await expect(page.getByTestId('error-state')).toContainText(/forced failure/i);
|
||||||
|
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
|
||||||
|
await page.unroute('**/api/engagements**', handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
117
e2e/tests/us6-deployment.spec.ts
Normal file
117
e2e/tests/us6-deployment.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* US-6 — deployment via Makefile + Docker.
|
||||||
|
*
|
||||||
|
* These checks are infrastructure-level: they shell out to `make` and the
|
||||||
|
* container runtime to assert the build/run targets behave correctly and
|
||||||
|
* the SQLite volume survives a restart.
|
||||||
|
*
|
||||||
|
* The container is expected to already be `up` when the suite starts (the
|
||||||
|
* harness runs `make build && make start && make create-admin` before
|
||||||
|
* Playwright). So `AC-6.1`/`AC-6.2` are verified via image presence + HTTP
|
||||||
|
* smoke; `AC-6.3` exercises stop/restart/logs; `AC-6.4` writes a row, restarts,
|
||||||
|
* and re-reads it; `AC-6.5` confirms the test targets exist as Makefile rules.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
adminToken,
|
||||||
|
createEngagement,
|
||||||
|
deleteAllEngagements,
|
||||||
|
listEngagements,
|
||||||
|
waitForHealth,
|
||||||
|
} from '../fixtures/api';
|
||||||
|
|
||||||
|
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
|
||||||
|
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
|
||||||
|
const IMAGE = process.env.MIMIC_IMAGE ?? 'mimic:latest';
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
function run(cmd: string, opts: { ignoreFail?: boolean } = {}): { status: number; out: string } {
|
||||||
|
try {
|
||||||
|
const out = execSync(cmd, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
return { status: 0, out };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||||
|
const out =
|
||||||
|
(typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '') +
|
||||||
|
(typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '');
|
||||||
|
if (opts.ignoreFail) return { status: err.status ?? -1, out };
|
||||||
|
throw new Error(`command failed (${err.status}): ${cmd}\n${out}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-6 — deployment via Docker + Makefile', () => {
|
||||||
|
test('AC-6.1 — image mimic:latest exists (built by make build)', () => {
|
||||||
|
const r = run(`${RUNTIME} images --format "{{.Repository}}:{{.Tag}}"`);
|
||||||
|
expect(r.out).toMatch(new RegExp(IMAGE.replace(/[.:]/g, '\\$&')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-6.2 — container responds on http://localhost:5000 (front + /api/health)', async () => {
|
||||||
|
await waitForHealth(5_000);
|
||||||
|
// Frontend is served at "/". Use 127.0.0.1 explicitly so envs where
|
||||||
|
// `localhost` resolves to ::1 (where the container port isn't bound)
|
||||||
|
// don't break this contract test.
|
||||||
|
const base = (process.env.MIMIC_BASE_URL ?? 'http://127.0.0.1:5000').replace(/\/$/, '');
|
||||||
|
const html = run(`curl -fsS ${base}/`).out;
|
||||||
|
expect(html).toMatch(/<!doctype html>/i);
|
||||||
|
expect(html.toLowerCase()).toMatch(/mimic/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-6.3 — make stop / make restart / make logs are well-formed targets', () => {
|
||||||
|
// Don't actually stop the container mid-suite — that would tear down
|
||||||
|
// the other tests. Instead, verify the Makefile rules exist and are
|
||||||
|
// syntactically valid by asking make for their recipes via `--just-print`.
|
||||||
|
// `make restart` expands to `make stop && make start`, so we look for
|
||||||
|
// those sub-commands instead of a literal "restart" token.
|
||||||
|
const dry = run('make --dry-run --no-print-directory stop restart logs');
|
||||||
|
expect(dry.out).toMatch(new RegExp(`${RUNTIME} stop ${CONTAINER}`));
|
||||||
|
expect(dry.out).toMatch(/make stop && make start/);
|
||||||
|
expect(dry.out).toMatch(new RegExp(`${RUNTIME} logs -f ${CONTAINER}`));
|
||||||
|
|
||||||
|
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
|
||||||
|
expect(makefile).toMatch(/^stop:/m);
|
||||||
|
expect(makefile).toMatch(/^restart:/m);
|
||||||
|
expect(makefile).toMatch(/^logs:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-6.4 — SQLite persists across container restart (named volume mimic-data)', async () => {
|
||||||
|
const token = await adminToken();
|
||||||
|
// Seed a unique engagement.
|
||||||
|
const marker = `AC-6.4-persistence-${Date.now()}`;
|
||||||
|
await createEngagement(token, { name: marker, start_date: '2026-12-01' });
|
||||||
|
|
||||||
|
// Restart the container (NOT make restart, since that would also rm —
|
||||||
|
// we do `runtime restart` which keeps the same container + volume).
|
||||||
|
run(`${RUNTIME} restart ${CONTAINER}`);
|
||||||
|
await waitForHealth(20_000);
|
||||||
|
|
||||||
|
const token2 = await adminToken();
|
||||||
|
const items = await listEngagements(token2);
|
||||||
|
const found = items.find((i) => i.name === marker);
|
||||||
|
expect(found, `engagement seeded before restart should survive`).toBeTruthy();
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
await deleteAllEngagements(token2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-6.5 — make test-backend / test-frontend / test-e2e are defined', () => {
|
||||||
|
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
|
||||||
|
expect(makefile).toMatch(/^test-backend:/m);
|
||||||
|
expect(makefile).toMatch(/^test-frontend:/m);
|
||||||
|
expect(makefile).toMatch(/^test-e2e:/m);
|
||||||
|
|
||||||
|
// Dry-run them to make sure the recipes are syntactically valid.
|
||||||
|
const dry = run('make --dry-run --no-print-directory test-backend test-frontend test-e2e');
|
||||||
|
expect(dry.out).toMatch(/pytest/);
|
||||||
|
expect(dry.out).toMatch(/npm run test/);
|
||||||
|
expect(dry.out).toMatch(/playwright test/);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
e2e/tsconfig.json
Normal file
18
e2e/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node", "@playwright/test"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@fixtures/*": ["fixtures/*"],
|
||||||
|
"@helpers/*": ["helpers/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
20
frontend/.eslintrc.cjs
Normal file
20
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2022: true, node: true },
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
|
||||||
|
settings: { react: { version: '18.3' } },
|
||||||
|
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
ignorePatterns: ['dist', 'node_modules', 'coverage', '*.config.js', '*.config.cjs'],
|
||||||
|
};
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mimic — BAS</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-canvas text-ink">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7532
frontend/package-lock.json
generated
Normal file
7532
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "mimic-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint . --max-warnings=0",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.27.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.1.0",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.8.1",
|
||||||
|
"@typescript-eslint/parser": "^8.8.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"axios-mock-adapter": "^2.1.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-plugin-react": "^7.37.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8",
|
||||||
|
"vitest": "^2.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#024ad8"/><text x="50%" y="58%" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="38" font-weight="700" fill="#fff">M</text></svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
47
frontend/src/App.tsx
Normal file
47
frontend/src/App.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
|
import { ToastViewport } from '@/components/Toast';
|
||||||
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
|
import { EngagementsListPage } from '@/pages/EngagementsListPage';
|
||||||
|
import { EngagementFormPage } from '@/pages/EngagementFormPage';
|
||||||
|
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
|
||||||
|
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router. Auth + role gates handled by <ProtectedRoute />.
|
||||||
|
* Default `/` redirects to /engagements (guarded — kicks to /login if no token).
|
||||||
|
*/
|
||||||
|
export function App(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* All authenticated routes share the Layout chrome. */}
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<Navigate to="/engagements" replace />} />
|
||||||
|
<Route path="/engagements" element={<EngagementsListPage />} />
|
||||||
|
<Route path="/engagements/:id" element={<EngagementDetailPage />} />
|
||||||
|
|
||||||
|
{/* redteam + admin write actions */}
|
||||||
|
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
|
||||||
|
<Route path="/engagements/new" element={<EngagementFormPage />} />
|
||||||
|
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* admin-only routes */}
|
||||||
|
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||||
|
<Route path="/admin/users" element={<UsersAdminPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
<ToastViewport />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/api/auth.ts
Normal file
16
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { LoginResponse, User } from './types';
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||||
|
const { data } = await apiClient.post<LoginResponse>('/auth/login', { username, password });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await apiClient.post('/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMe(): Promise<User> {
|
||||||
|
const { data } = await apiClient.get<User>('/auth/me');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
69
frontend/src/api/client.ts
Normal file
69
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const TOKEN_STORAGE_KEY = 'mimic.token';
|
||||||
|
|
||||||
|
let memoryToken: string | null = null;
|
||||||
|
|
||||||
|
export function setToken(token: string | null): void {
|
||||||
|
memoryToken = token;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (memoryToken) return memoryToken;
|
||||||
|
memoryToken = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
|
return memoryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callbacks the auth/toast layer registers so the interceptor can react
|
||||||
|
* to 401 (purge + redirect to /login + toast).
|
||||||
|
*/
|
||||||
|
type UnauthorizedHandler = () => void;
|
||||||
|
let onUnauthorized: UnauthorizedHandler | null = null;
|
||||||
|
|
||||||
|
export function registerUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
setToken(null);
|
||||||
|
onUnauthorized?.();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a user-facing message from a thrown axios error.
|
||||||
|
* Backend uses {error: "<message>"} shape.
|
||||||
|
*/
|
||||||
|
export function extractApiError(err: unknown, fallback = 'Une erreur est survenue'): string {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const data = err.response?.data as { error?: string } | undefined;
|
||||||
|
if (data?.error) return data.error;
|
||||||
|
if (err.message) return err.message;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
29
frontend/src/api/engagements.ts
Normal file
29
frontend/src/api/engagements.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { Engagement, EngagementInput } from './types';
|
||||||
|
|
||||||
|
export async function listEngagements(): Promise<Engagement[]> {
|
||||||
|
const { data } = await apiClient.get<Engagement[]>('/engagements');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEngagement(id: number): Promise<Engagement> {
|
||||||
|
const { data } = await apiClient.get<Engagement>(`/engagements/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEngagement(input: EngagementInput): Promise<Engagement> {
|
||||||
|
const { data } = await apiClient.post<Engagement>('/engagements', input);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchEngagement(
|
||||||
|
id: number,
|
||||||
|
input: Partial<EngagementInput>,
|
||||||
|
): Promise<Engagement> {
|
||||||
|
const { data } = await apiClient.patch<Engagement>(`/engagements/${id}`, input);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEngagement(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/engagements/${id}`);
|
||||||
|
}
|
||||||
54
frontend/src/api/types.ts
Normal file
54
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type Role = 'admin' | 'redteam' | 'soc';
|
||||||
|
|
||||||
|
export type EngagementStatus = 'planned' | 'active' | 'closed';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
user: Pick<User, 'id' | 'username' | 'role'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementCreatedBy {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Engagement {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
status: EngagementStatus;
|
||||||
|
created_at: string;
|
||||||
|
created_by: EngagementCreatedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string | null;
|
||||||
|
status?: EngagementStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCreateInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPatchInput {
|
||||||
|
role?: Role;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
21
frontend/src/api/users.ts
Normal file
21
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { User, UserCreateInput, UserPatchInput } from './types';
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<User[]> {
|
||||||
|
const { data } = await apiClient.get<User[]>('/users');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(input: UserCreateInput): Promise<User> {
|
||||||
|
const { data } = await apiClient.post<User>('/users', input);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchUser(id: number, input: UserPatchInput): Promise<User> {
|
||||||
|
const { data } = await apiClient.patch<User>(`/users/${id}`, input);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/users/${id}`);
|
||||||
|
}
|
||||||
20
frontend/src/components/EmptyState.tsx
Normal file
20
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, action }: EmptyStateProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="empty-state"
|
||||||
|
className="card-product flex flex-col items-start gap-md border border-hairline"
|
||||||
|
>
|
||||||
|
<h2 className="text-[24px] font-medium text-ink">{title}</h2>
|
||||||
|
{description ? <p className="text-[16px] text-charcoal">{description}</p> : null}
|
||||||
|
{action ? <div className="pt-xs">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/ErrorState.tsx
Normal file
23
frontend/src/components/ErrorState.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
interface ErrorStateProps {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="error-state"
|
||||||
|
className="card-product border border-bloom-deep/20 flex flex-col items-start gap-md"
|
||||||
|
>
|
||||||
|
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
|
||||||
|
<p className="text-[16px] text-charcoal">{message}</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<button type="button" className="btn-outline" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/src/components/FormField.tsx
Normal file
63
frontend/src/components/FormField.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface BaseProps {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
error?: string | null;
|
||||||
|
hint?: string;
|
||||||
|
required?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({ label, htmlFor, error, hint, required, children }: BaseProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-xs">
|
||||||
|
<label htmlFor={htmlFor} className="text-[14px] font-medium text-ink">
|
||||||
|
{label}
|
||||||
|
{required ? <span className="text-bloom-deep ml-1">*</span> : null}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<span role="alert" className="text-[12px] text-bloom-deep">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : hint ? (
|
||||||
|
<span className="text-[12px] text-graphite">{hint}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>): JSX.Element {
|
||||||
|
return <input {...props} className={`text-input ${props.className ?? ''}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
className={`text-input min-h-[112px] py-sm ${props.className ?? ''}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends Omit<InputHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ options, className, ...rest }: SelectProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<select {...rest} className={`text-input ${className ?? ''}`}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/components/Layout.tsx
Normal file
91
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top utility strip (ink) + main nav (canvas).
|
||||||
|
* Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app.
|
||||||
|
*/
|
||||||
|
export function Layout(): JSX.Element {
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full flex flex-col bg-canvas">
|
||||||
|
{/* utility-strip — ink slab, fine print */}
|
||||||
|
<div className="bg-ink text-ink-on text-[14px] h-9 flex items-center">
|
||||||
|
<div className="mx-auto w-full max-w-page px-xl flex items-center justify-between">
|
||||||
|
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-md">
|
||||||
|
<span className="text-[12px] uppercase tracking-[0.5px] text-steel">
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px]">{user.username}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-[14px] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* nav-bar-top — canvas with hairline */}
|
||||||
|
<header className="bg-canvas border-b border-hairline">
|
||||||
|
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
|
||||||
|
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||||
|
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
|
||||||
|
<span className="text-[20px] font-medium tracking-tight">Mimic</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-md">
|
||||||
|
<NavLink
|
||||||
|
to="/engagements"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-[16px] py-2 px-md ${
|
||||||
|
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Engagements
|
||||||
|
</NavLink>
|
||||||
|
{isAdmin ? (
|
||||||
|
<NavLink
|
||||||
|
to="/admin/users"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-[16px] py-2 px-md ${
|
||||||
|
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</NavLink>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* page body */}
|
||||||
|
<main className="flex-1 bg-canvas">
|
||||||
|
<div className="mx-auto w-full max-w-page px-xl py-xxl">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* footer — ink slab close */}
|
||||||
|
<footer className="bg-ink text-ink-on">
|
||||||
|
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-steel">
|
||||||
|
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/components/LoadingState.tsx
Normal file
13
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function LoadingState({ label = 'Loading…' }: { label?: string }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="loading-state"
|
||||||
|
className="flex items-center justify-center py-section text-graphite text-[16px]"
|
||||||
|
>
|
||||||
|
<span className="inline-block h-2 w-2 rounded-pill bg-primary animate-pulse mr-sm" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/ProtectedRoute.tsx
Normal file
51
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import type { Role } from '@/api/types';
|
||||||
|
import { LoadingState } from './LoadingState';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
/** Allowed roles. If omitted, any authenticated user passes. */
|
||||||
|
roles?: Role[];
|
||||||
|
/** Where to send users who lack the required role. */
|
||||||
|
redirectOnRoleDenied?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate component: handles auth + role checks before rendering nested routes.
|
||||||
|
* - No token / no user → /login
|
||||||
|
* - Wrong role → redirect + "Accès refusé" toast (AC-3.7)
|
||||||
|
*/
|
||||||
|
export function ProtectedRoute({
|
||||||
|
roles,
|
||||||
|
redirectOnRoleDenied = '/engagements',
|
||||||
|
}: ProtectedRouteProps): JSX.Element {
|
||||||
|
const { user, status } = useAuth();
|
||||||
|
const { push } = useToast();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const roleDenied = Boolean(
|
||||||
|
status === 'authenticated' && user && roles && !roles.includes(user.role),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roleDenied) {
|
||||||
|
push('Accès refusé', 'error');
|
||||||
|
}
|
||||||
|
}, [roleDenied, push]);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return <LoadingState label="Loading session…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated' || !user) {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleDenied) {
|
||||||
|
return <Navigate to={redirectOnRoleDenied} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
27
frontend/src/components/StatusBadge.tsx
Normal file
27
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { EngagementStatus } from '@/api/types';
|
||||||
|
|
||||||
|
const LABELS: Record<EngagementStatus, string> = {
|
||||||
|
planned: 'Planned',
|
||||||
|
active: 'Active',
|
||||||
|
closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLES: Record<EngagementStatus, string> = {
|
||||||
|
// Outlined ink for planned (neutral), filled primary for active (engagement live),
|
||||||
|
// outlined steel for closed (muted). Stays within DESIGN.md palette.
|
||||||
|
planned: 'bg-canvas text-ink border border-ink',
|
||||||
|
active: 'bg-primary text-ink-on',
|
||||||
|
closed: 'bg-cloud text-graphite border border-hairline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
|
||||||
|
data-testid="status-badge"
|
||||||
|
data-status={status}
|
||||||
|
>
|
||||||
|
{LABELS[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/components/Toast.tsx
Normal file
47
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stack of toast notifications anchored bottom-right.
|
||||||
|
* Pure DESIGN.md surfaces: rounded-xl, soft-lift, ink slab for errors.
|
||||||
|
*/
|
||||||
|
export function ToastViewport(): JSX.Element {
|
||||||
|
const { toasts, dismiss } = useToast();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
||||||
|
>
|
||||||
|
{toasts.map((t) => {
|
||||||
|
const isError = t.kind === 'error';
|
||||||
|
const isSuccess = t.kind === 'success';
|
||||||
|
const surface = isError
|
||||||
|
? 'bg-ink text-ink-on'
|
||||||
|
: isSuccess
|
||||||
|
? 'bg-primary text-ink-on'
|
||||||
|
: 'bg-canvas text-ink border border-hairline';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
role="status"
|
||||||
|
data-testid="toast"
|
||||||
|
data-kind={t.kind}
|
||||||
|
className={`pointer-events-auto rounded-xl px-md py-sm shadow-soft-lift text-[14px] leading-[1.4] ${surface}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-sm">
|
||||||
|
<span className="flex-1">{t.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
className="text-current opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/hooks/useAuth.tsx
Normal file
112
frontend/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { login as apiLogin, logout as apiLogout, fetchMe } from '@/api/auth';
|
||||||
|
import { getToken, registerUnauthorizedHandler, setToken } from '@/api/client';
|
||||||
|
import type { Role, User } from '@/api/types';
|
||||||
|
import { useToast } from './useToast';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: User | null;
|
||||||
|
status: 'loading' | 'authenticated' | 'unauthenticated';
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isRedteam: boolean;
|
||||||
|
isSoc: boolean;
|
||||||
|
canEditEngagements: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
|
||||||
|
const { push } = useToast();
|
||||||
|
|
||||||
|
// Bootstrap: if a token exists in storage, try to recover the session.
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
fetchMe()
|
||||||
|
.then((u) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser(u);
|
||||||
|
setStatus('authenticated');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 401 interceptor already purges token + triggers redirect/toast.
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Register the global 401 handler once.
|
||||||
|
useEffect(() => {
|
||||||
|
registerUnauthorizedHandler(() => {
|
||||||
|
setUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
push('Session expirée', 'error');
|
||||||
|
// Hard redirect so any in-flight query state is cleared.
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.assign('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [push]);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const resp = await apiLogin(username, password);
|
||||||
|
setToken(resp.access_token);
|
||||||
|
// Hydrate full user (with created_at) via /me.
|
||||||
|
const me = await fetchMe();
|
||||||
|
setUser(me);
|
||||||
|
setStatus('authenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiLogout();
|
||||||
|
} catch {
|
||||||
|
// Logout is best-effort server-side; the client purge below is what matters.
|
||||||
|
}
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(() => {
|
||||||
|
const role: Role | undefined = user?.role;
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
status,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAdmin: role === 'admin',
|
||||||
|
isRedteam: role === 'redteam',
|
||||||
|
isSoc: role === 'soc',
|
||||||
|
canEditEngagements: role === 'admin' || role === 'redteam',
|
||||||
|
};
|
||||||
|
}, [user, status, login, logout]);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
50
frontend/src/hooks/useEngagements.ts
Normal file
50
frontend/src/hooks/useEngagements.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
createEngagement,
|
||||||
|
deleteEngagement,
|
||||||
|
fetchEngagement,
|
||||||
|
listEngagements,
|
||||||
|
patchEngagement,
|
||||||
|
} from '@/api/engagements';
|
||||||
|
import type { EngagementInput } from '@/api/types';
|
||||||
|
|
||||||
|
const KEY = ['engagements'] as const;
|
||||||
|
|
||||||
|
export function useEngagementsList() {
|
||||||
|
return useQuery({ queryKey: KEY, queryFn: listEngagements });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEngagement(id: number | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...KEY, id],
|
||||||
|
queryFn: () => fetchEngagement(id as number),
|
||||||
|
enabled: typeof id === 'number' && !Number.isNaN(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEngagement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: EngagementInput) => createEngagement(input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchEngagement(id: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: Partial<EngagementInput>) => patchEngagement(id, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: [...KEY, id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEngagement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteEngagement(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
58
frontend/src/hooks/useToast.tsx
Normal file
58
frontend/src/hooks/useToast.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export type ToastKind = 'info' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: Toast[];
|
||||||
|
push: (message: string, kind?: ToastKind) => void;
|
||||||
|
dismiss: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
const DEFAULT_DURATION_MS = 4000;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const counterRef = useRef(0);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(message: string, kind: ToastKind = 'info') => {
|
||||||
|
counterRef.current += 1;
|
||||||
|
const id = counterRef.current;
|
||||||
|
setToasts((prev) => [...prev, { id, message, kind }]);
|
||||||
|
setTimeout(() => dismiss(id), DEFAULT_DURATION_MS);
|
||||||
|
},
|
||||||
|
[dismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
||||||
|
|
||||||
|
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useToast must be used inside ToastProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
33
frontend/src/hooks/useUsers.ts
Normal file
33
frontend/src/hooks/useUsers.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createUser, deleteUser, listUsers, patchUser } from '@/api/users';
|
||||||
|
import type { UserCreateInput, UserPatchInput } from '@/api/types';
|
||||||
|
|
||||||
|
const KEY = ['users'] as const;
|
||||||
|
|
||||||
|
export function useUsersList() {
|
||||||
|
return useQuery({ queryKey: KEY, queryFn: listUsers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: UserCreateInput) => createUser(input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: UserPatchInput }) => patchUser(id, input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteUser(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { App } from './App';
|
||||||
|
import { AuthProvider } from './hooks/useAuth';
|
||||||
|
import { ToastProvider } from './hooks/useToast';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) throw new Error('Missing #root element');
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
84
frontend/src/pages/EngagementDetailPage.tsx
Normal file
84
frontend/src/pages/EngagementDetailPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { extractApiError } from '@/api/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useEngagement } from '@/hooks/useEngagements';
|
||||||
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
|
||||||
|
export function EngagementDetailPage(): JSX.Element {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const numericId = id ? Number(id) : undefined;
|
||||||
|
const { canEditEngagements } = useAuth();
|
||||||
|
|
||||||
|
const detail = useEngagement(numericId);
|
||||||
|
|
||||||
|
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||||
|
if (detail.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||||
|
onRetry={() => detail.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!detail.data) return <ErrorState message="Engagement not found" />;
|
||||||
|
|
||||||
|
const eng = detail.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-xl">
|
||||||
|
<header className="flex items-start justify-between gap-md">
|
||||||
|
<div className="flex flex-col gap-sm">
|
||||||
|
<Link to="/engagements" className="btn-text-link text-[14px]">
|
||||||
|
← Back to engagements
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-[44px] font-medium leading-none">{eng.name}</h1>
|
||||||
|
<div className="flex items-center gap-md">
|
||||||
|
<StatusBadge status={eng.status} />
|
||||||
|
<span className="text-[14px] text-graphite">
|
||||||
|
Created by <span className="text-ink">{eng.created_by.username}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEditEngagements ? (
|
||||||
|
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||||
|
<div className="card-product">
|
||||||
|
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-md text-[14px]">
|
||||||
|
<dt className="text-graphite">Start date</dt>
|
||||||
|
<dd className="text-ink">{eng.start_date}</dd>
|
||||||
|
<dt className="text-graphite">End date</dt>
|
||||||
|
<dd className="text-ink">{eng.end_date ?? '—'}</dd>
|
||||||
|
<dt className="text-graphite">Status</dt>
|
||||||
|
<dd className="text-ink capitalize">{eng.status}</dd>
|
||||||
|
<dt className="text-graphite">Created at</dt>
|
||||||
|
<dd className="text-ink">{eng.created_at}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-product">
|
||||||
|
<h2 className="text-[20px] font-medium mb-md">Description</h2>
|
||||||
|
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||||
|
{eng.description?.trim() ? eng.description : 'No description provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sprint 2 placeholder per AC-4.9 */}
|
||||||
|
<section className="bg-ink text-ink-on rounded-xl p-xxl">
|
||||||
|
<h2 className="text-[32px] font-medium leading-none">Simulations</h2>
|
||||||
|
<p className="text-[16px] mt-sm text-steel">
|
||||||
|
Simulations à venir au Sprint 2 — tracking of red team tests and SOC detection coverage
|
||||||
|
will live here.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
frontend/src/pages/EngagementFormPage.tsx
Normal file
219
frontend/src/pages/EngagementFormPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { extractApiError } from '@/api/client';
|
||||||
|
import type { EngagementInput, EngagementStatus } from '@/api/types';
|
||||||
|
import {
|
||||||
|
useCreateEngagement,
|
||||||
|
useEngagement,
|
||||||
|
usePatchEngagement,
|
||||||
|
} from '@/hooks/useEngagements';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||||
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||||
|
{ value: 'planned', label: 'Planned' },
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'closed', label: 'Closed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
status: EngagementStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: FormState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
status: 'planned',
|
||||||
|
};
|
||||||
|
|
||||||
|
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||||
|
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||||
|
if (!state.name.trim()) errors.name = 'Name is required';
|
||||||
|
if (!state.start_date) errors.start_date = 'Start date is required';
|
||||||
|
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||||
|
errors.end_date = 'End date must be on or after start date';
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EngagementFormPage(): JSX.Element {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const editing = Boolean(id);
|
||||||
|
const numericId = id ? Number(id) : undefined;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { push } = useToast();
|
||||||
|
|
||||||
|
const detail = useEngagement(editing ? numericId : undefined);
|
||||||
|
const createMutation = useCreateEngagement();
|
||||||
|
const patchMutation = usePatchEngagement(numericId ?? 0);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY);
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Hydrate edit form when data arrives.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && detail.data) {
|
||||||
|
setForm({
|
||||||
|
name: detail.data.name,
|
||||||
|
description: detail.data.description ?? '',
|
||||||
|
start_date: detail.data.start_date,
|
||||||
|
end_date: detail.data.end_date ?? '',
|
||||||
|
status: detail.data.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editing, detail.data]);
|
||||||
|
|
||||||
|
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||||
|
if (editing && detail.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||||
|
onRetry={() => detail.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitError(null);
|
||||||
|
const v = validate(form);
|
||||||
|
setErrors(v);
|
||||||
|
if (Object.keys(v).length > 0) return;
|
||||||
|
|
||||||
|
const payload: EngagementInput = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
start_date: form.start_date,
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
if (form.description.trim()) payload.description = form.description.trim();
|
||||||
|
// PATCH with null clears end_date; POST with omitted leaves it null
|
||||||
|
if (editing) {
|
||||||
|
// Always include end_date for edit: '' → null to clear, otherwise value
|
||||||
|
payload.end_date = form.end_date === '' ? null : form.end_date;
|
||||||
|
} else if (form.end_date) {
|
||||||
|
payload.end_date = form.end_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editing && numericId) {
|
||||||
|
await patchMutation.mutateAsync(payload);
|
||||||
|
push('Engagement updated', 'success');
|
||||||
|
navigate(`/engagements/${numericId}`);
|
||||||
|
} else {
|
||||||
|
const created = await createMutation.mutateAsync(payload);
|
||||||
|
push('Engagement created', 'success');
|
||||||
|
navigate(`/engagements/${created.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(extractApiError(err, 'Could not save engagement'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitting = createMutation.isPending || patchMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-xl max-w-2xl">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-[44px] font-medium leading-none">
|
||||||
|
{editing ? 'Edit engagement' : 'New engagement'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-charcoal text-[16px] mt-sm">
|
||||||
|
{editing
|
||||||
|
? 'Update the engagement metadata.'
|
||||||
|
: 'Create a new red team mission to host simulations.'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||||
|
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||||
|
<TextInput
|
||||||
|
id="eng-name"
|
||||||
|
name="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Description" htmlFor="eng-description">
|
||||||
|
<TextArea
|
||||||
|
id="eng-description"
|
||||||
|
name="description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||||
|
<FormField
|
||||||
|
label="Start date"
|
||||||
|
htmlFor="eng-start"
|
||||||
|
required
|
||||||
|
error={errors.start_date}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="eng-start"
|
||||||
|
type="date"
|
||||||
|
name="start_date"
|
||||||
|
value={form.start_date}
|
||||||
|
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="End date"
|
||||||
|
htmlFor="eng-end"
|
||||||
|
hint="Leave empty to clear / leave open-ended"
|
||||||
|
error={errors.end_date}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="eng-end"
|
||||||
|
type="date"
|
||||||
|
name="end_date"
|
||||||
|
value={form.end_date}
|
||||||
|
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Status" htmlFor="eng-status" required>
|
||||||
|
<Select
|
||||||
|
id="eng-status"
|
||||||
|
name="status"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-md pt-sm">
|
||||||
|
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
|
||||||
|
className="btn-outline-ink"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/pages/EngagementsListPage.tsx
Normal file
126
frontend/src/pages/EngagementsListPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { extractApiError } from '@/api/client';
|
||||||
|
import type { Engagement } from '@/api/types';
|
||||||
|
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EngagementsListPage(): JSX.Element {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useEngagementsList();
|
||||||
|
const { canEditEngagements } = useAuth();
|
||||||
|
const { push } = useToast();
|
||||||
|
const deleteMutation = useDeleteEngagement();
|
||||||
|
|
||||||
|
const onDelete = async (eng: Engagement) => {
|
||||||
|
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(eng.id);
|
||||||
|
push('Engagement supprimé', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
push(extractApiError(err, 'Suppression impossible'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-xl">
|
||||||
|
<header className="flex items-end justify-between gap-md">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[44px] font-medium leading-none">Engagements</h1>
|
||||||
|
<p className="text-charcoal text-[16px] mt-sm">
|
||||||
|
Red team missions and their lifecycle status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEditEngagements ? (
|
||||||
|
<Link to="/engagements/new" className="btn-primary">
|
||||||
|
New engagement
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !isError && data && data.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No engagements yet"
|
||||||
|
description="Create your first engagement to start tracking red team missions."
|
||||||
|
action={
|
||||||
|
canEditEngagements ? (
|
||||||
|
<Link to="/engagements/new" className="btn-primary">
|
||||||
|
Create engagement
|
||||||
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !isError && data && data.length > 0 ? (
|
||||||
|
<div className="card-product overflow-hidden p-0">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-cloud border-b border-hairline">
|
||||||
|
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||||
|
<th className="px-xl py-md">Name</th>
|
||||||
|
<th className="px-xl py-md">Status</th>
|
||||||
|
<th className="px-xl py-md">Start</th>
|
||||||
|
<th className="px-xl py-md">End</th>
|
||||||
|
<th className="px-xl py-md">Created by</th>
|
||||||
|
<th className="px-xl py-md text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((eng) => (
|
||||||
|
<tr key={eng.id} className="border-b border-hairline last:border-0">
|
||||||
|
<td className="px-xl py-md">
|
||||||
|
<Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline">
|
||||||
|
{eng.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-xl py-md">
|
||||||
|
<StatusBadge status={eng.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-xl py-md text-charcoal">{formatDate(eng.start_date)}</td>
|
||||||
|
<td className="px-xl py-md text-charcoal">{formatDate(eng.end_date)}</td>
|
||||||
|
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td>
|
||||||
|
<td className="px-xl py-md text-right">
|
||||||
|
<div className="inline-flex gap-sm">
|
||||||
|
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
{canEditEngagements ? (
|
||||||
|
<>
|
||||||
|
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-text-link text-bloom-deep"
|
||||||
|
onClick={() => onDelete(eng)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/pages/LoginPage.tsx
Normal file
90
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { extractApiError } from '@/api/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { FormField, TextInput } from '@/components/FormField';
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage(): JSX.Element {
|
||||||
|
const { login, status, user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const fromPath = (location.state as LocationState | null)?.from;
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Already authenticated → bounce to engagements (e.g. user navigates back to /login).
|
||||||
|
// Returning <Navigate> instead of calling navigate() during render avoids the
|
||||||
|
// "Cannot update a component while rendering a different component" warning.
|
||||||
|
if (status === 'authenticated' && user) {
|
||||||
|
return <Navigate to={fromPath ?? '/engagements'} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate(fromPath ?? '/engagements', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err, 'Invalid credentials'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-cloud flex items-center justify-center px-md">
|
||||||
|
<div className="w-full max-w-md card-product flex flex-col gap-lg">
|
||||||
|
{/* Chevron echo of the brand mark */}
|
||||||
|
<div className="flex items-center gap-sm">
|
||||||
|
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
|
||||||
|
<h1 className="text-[32px] font-medium leading-none">Mimic</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-md">
|
||||||
|
<FormField label="Username" htmlFor="login-username" required>
|
||||||
|
<TextInput
|
||||||
|
id="login-username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Password" htmlFor="login-password" required>
|
||||||
|
<TextInput
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" data-testid="login-error" className="text-[14px] text-bloom-deep">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
frontend/src/pages/UsersAdminPage.tsx
Normal file
276
frontend/src/pages/UsersAdminPage.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { Fragment, useState, type FormEvent } from 'react';
|
||||||
|
import { extractApiError } from '@/api/client';
|
||||||
|
import type { Role, User } from '@/api/types';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import {
|
||||||
|
useCreateUser,
|
||||||
|
useDeleteUser,
|
||||||
|
usePatchUser,
|
||||||
|
useUsersList,
|
||||||
|
} from '@/hooks/useUsers';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { FormField, Select, TextInput } from '@/components/FormField';
|
||||||
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
|
|
||||||
|
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'redteam', label: 'Red Team' },
|
||||||
|
{ value: 'soc', label: 'SOC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CreateFormState {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
||||||
|
|
||||||
|
export function UsersAdminPage(): JSX.Element {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const { push } = useToast();
|
||||||
|
const list = useUsersList();
|
||||||
|
const createMutation = useCreateUser();
|
||||||
|
const patchMutation = usePatchUser();
|
||||||
|
const deleteMutation = useDeleteUser();
|
||||||
|
|
||||||
|
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Per-row password reset state. Only one row open at a time.
|
||||||
|
const [resetOpen, setResetOpen] = useState<number | null>(null);
|
||||||
|
const [resetPassword, setResetPassword] = useState('');
|
||||||
|
|
||||||
|
const onCreate = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreateError(null);
|
||||||
|
if (createForm.password.length < 8) {
|
||||||
|
setCreateError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync(createForm);
|
||||||
|
setCreateForm(EMPTY_CREATE);
|
||||||
|
push('User created', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(extractApiError(err, 'Could not create user'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRoleChange = async (u: User, role: Role) => {
|
||||||
|
if (u.role === role) return;
|
||||||
|
try {
|
||||||
|
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
||||||
|
push(`Role updated for ${u.username}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
push(extractApiError(err, 'Could not update role'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResetPassword = async (u: User, e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (resetPassword.length < 8) {
|
||||||
|
push('Password must be at least 8 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
||||||
|
push(`Password reset for ${u.username}`, 'success');
|
||||||
|
setResetOpen(null);
|
||||||
|
setResetPassword('');
|
||||||
|
} catch (err) {
|
||||||
|
push(extractApiError(err, 'Could not reset password'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (u: User) => {
|
||||||
|
if (currentUser?.id === u.id) {
|
||||||
|
push('You cannot delete your own account', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`Delete user "${u.username}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(u.id);
|
||||||
|
push('User deleted', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
push(extractApiError(err, 'Could not delete user'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-xl">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-[44px] font-medium leading-none">User accounts</h1>
|
||||||
|
<p className="text-charcoal text-[16px] mt-sm">
|
||||||
|
Manage local accounts. Admins can create new red team or SOC analysts.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="card-product flex flex-col gap-md">
|
||||||
|
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||||
|
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
||||||
|
<FormField label="Username" htmlFor="new-username" required>
|
||||||
|
<TextInput
|
||||||
|
id="new-username"
|
||||||
|
value={createForm.username}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Password" htmlFor="new-password" required hint="≥ 8 characters">
|
||||||
|
<TextInput
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={createForm.password}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Role" htmlFor="new-role" required>
|
||||||
|
<Select
|
||||||
|
id="new-role"
|
||||||
|
value={createForm.role}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||||
|
options={ROLE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{createError ? (
|
||||||
|
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-md">
|
||||||
|
<h2 className="text-[20px] font-medium">All accounts</h2>
|
||||||
|
|
||||||
|
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
|
||||||
|
{list.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
message={extractApiError(list.error, 'Could not load users')}
|
||||||
|
onRetry={() => list.refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No users yet"
|
||||||
|
description="Create the first account using the form above."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
|
||||||
|
<div className="card-product overflow-hidden p-0">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-cloud border-b border-hairline">
|
||||||
|
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||||
|
<th className="px-xl py-md">Username</th>
|
||||||
|
<th className="px-xl py-md">Role</th>
|
||||||
|
<th className="px-xl py-md">Created</th>
|
||||||
|
<th className="px-xl py-md text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.data.map((u) => {
|
||||||
|
const isSelf = currentUser?.id === u.id;
|
||||||
|
return (
|
||||||
|
// Fragment must carry the key — `<>` cannot, which broke
|
||||||
|
// per-row reconciliation (reset-password state leaked across rows).
|
||||||
|
<Fragment key={u.id}>
|
||||||
|
<tr className="border-b border-hairline last:border-0">
|
||||||
|
<td className="px-xl py-md font-medium text-ink">
|
||||||
|
{u.username}
|
||||||
|
{isSelf ? (
|
||||||
|
<span className="ml-sm text-[12px] text-graphite uppercase tracking-[0.5px]">
|
||||||
|
(you)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-xl py-md">
|
||||||
|
<Select
|
||||||
|
value={u.role}
|
||||||
|
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
||||||
|
options={ROLE_OPTIONS}
|
||||||
|
aria-label={`Change role for ${u.username}`}
|
||||||
|
disabled={patchMutation.isPending}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-xl py-md text-charcoal">{u.created_at}</td>
|
||||||
|
<td className="px-xl py-md text-right">
|
||||||
|
<div className="inline-flex gap-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-text-link"
|
||||||
|
onClick={() => {
|
||||||
|
setResetOpen(resetOpen === u.id ? null : u.id);
|
||||||
|
setResetPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-text-link text-bloom-deep disabled:text-steel"
|
||||||
|
disabled={isSelf || deleteMutation.isPending}
|
||||||
|
onClick={() => onDelete(u)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{resetOpen === u.id ? (
|
||||||
|
<tr className="border-b border-hairline last:border-0 bg-cloud">
|
||||||
|
<td colSpan={4} className="px-xl py-md">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => onResetPassword(u, e)}
|
||||||
|
className="flex items-end gap-md"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
label={`New password for ${u.username}`}
|
||||||
|
htmlFor={`reset-${u.id}`}
|
||||||
|
hint="≥ 8 characters"
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id={`reset-${u.id}`}
|
||||||
|
type="password"
|
||||||
|
value={resetPassword}
|
||||||
|
onChange={(e) => setResetPassword(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
Save password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-outline-ink"
|
||||||
|
onClick={() => {
|
||||||
|
setResetOpen(null);
|
||||||
|
setResetPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/styles/fonts.css
Normal file
6
frontend/src/styles/fonts.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/*
|
||||||
|
* Inter Variable — bundled locally via @fontsource-variable/inter.
|
||||||
|
* NO remote CDN / Google Fonts loading at runtime.
|
||||||
|
* Forma DJR Micro substitute per DESIGN.md §Note on Font Substitutes.
|
||||||
|
*/
|
||||||
|
@import '@fontsource-variable/inter/index.css';
|
||||||
94
frontend/src/styles/index.css
Normal file
94
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@import './fonts.css';
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-canvas text-ink font-sans antialiased;
|
||||||
|
/* DESIGN.md: body line-height 1.4 when substituting Inter */
|
||||||
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compensate for Inter being slightly narrower than Forma DJR Micro (~3%) */
|
||||||
|
:root {
|
||||||
|
font-size: 16.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/*
|
||||||
|
* DESIGN.md component recipes.
|
||||||
|
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
||||||
|
*/
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center bg-primary text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
@apply bg-primary-deep;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
@apply bg-steel cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ink {
|
||||||
|
@apply inline-flex items-center justify-center bg-ink text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
|
}
|
||||||
|
.btn-ink:hover {
|
||||||
|
@apply bg-ink-soft;
|
||||||
|
}
|
||||||
|
.btn-ink:disabled {
|
||||||
|
@apply bg-steel cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply inline-flex items-center justify-center bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
|
}
|
||||||
|
.btn-outline:hover {
|
||||||
|
@apply bg-primary-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-ink {
|
||||||
|
@apply inline-flex items-center justify-center bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
|
}
|
||||||
|
.btn-outline-ink:hover {
|
||||||
|
@apply bg-cloud;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text-link {
|
||||||
|
@apply inline-flex items-center text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
@apply block w-full bg-canvas text-ink rounded-md border border-steel px-md py-sm h-11 text-[16px] leading-[1.38] focus:outline-none focus:border-ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-product {
|
||||||
|
@apply bg-canvas rounded-xl p-xl shadow-soft-lift;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pill-ink {
|
||||||
|
@apply inline-flex items-center bg-ink text-ink-on rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pill-outline {
|
||||||
|
@apply inline-flex items-center bg-canvas text-ink border border-ink rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
frontend/tailwind.config.ts
Normal file
101
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens mirror DESIGN.md.
|
||||||
|
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
|
||||||
|
*/
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Brand & Accent
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#024ad8',
|
||||||
|
bright: '#296ef9',
|
||||||
|
deep: '#0e3191',
|
||||||
|
soft: '#c9e0fc',
|
||||||
|
},
|
||||||
|
// Surface
|
||||||
|
canvas: '#ffffff',
|
||||||
|
paper: '#ffffff',
|
||||||
|
cloud: '#f7f7f7',
|
||||||
|
fog: '#e8e8e8',
|
||||||
|
steel: '#c2c2c2',
|
||||||
|
hairline: '#e8e8e8',
|
||||||
|
// Text
|
||||||
|
ink: {
|
||||||
|
DEFAULT: '#1a1a1a',
|
||||||
|
deep: '#000000',
|
||||||
|
soft: '#292929',
|
||||||
|
on: '#ffffff',
|
||||||
|
},
|
||||||
|
charcoal: '#3d3d3d',
|
||||||
|
graphite: '#636363',
|
||||||
|
// Semantic / decorative
|
||||||
|
bloom: {
|
||||||
|
coral: '#ff5050',
|
||||||
|
rose: '#f9d4d2',
|
||||||
|
deep: '#b3262b',
|
||||||
|
wine: '#5a1313',
|
||||||
|
},
|
||||||
|
storm: {
|
||||||
|
mist: '#8ebdce',
|
||||||
|
sea: '#7fadbe',
|
||||||
|
deep: '#356373',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
// DESIGN.md typography scale
|
||||||
|
'display-xxl': ['72px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||||
|
'display-xl': ['56px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||||
|
'display-lg': ['44px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||||
|
'display-md': ['32px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||||
|
'display-sm': ['24px', { lineHeight: '1.17', fontWeight: '500' }],
|
||||||
|
'display-xs': ['20px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||||
|
'body-lg': ['18px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||||
|
'body-md': ['16px', { lineHeight: '1.38', fontWeight: '400' }],
|
||||||
|
'body-emphasis': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||||
|
'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }],
|
||||||
|
'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }],
|
||||||
|
'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||||
|
'link-md': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||||
|
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600', letterSpacing: '0.7px' }],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
// DESIGN.md spacing tokens (named, complement Tailwind defaults)
|
||||||
|
xxs: '4px',
|
||||||
|
xs: '8px',
|
||||||
|
sm: '12px',
|
||||||
|
md: '16px',
|
||||||
|
lg: '20px',
|
||||||
|
xl: '24px',
|
||||||
|
xxl: '32px',
|
||||||
|
section: '80px',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
// DESIGN.md radius tokens
|
||||||
|
none: '0px',
|
||||||
|
xs: '2px',
|
||||||
|
sm: '3px',
|
||||||
|
md: '4px',
|
||||||
|
lg: '8px',
|
||||||
|
xl: '16px',
|
||||||
|
pill: '9999px',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
|
||||||
|
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
page: '1366px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
80
frontend/tests/ProtectedRoute.test.tsx
Normal file
80
frontend/tests/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
|
import { ToastProvider } from '@/hooks/useToast';
|
||||||
|
import { AuthProvider } from '@/hooks/useAuth';
|
||||||
|
import { setToken } from '@/api/client';
|
||||||
|
import { ToastViewport } from '@/components/Toast';
|
||||||
|
|
||||||
|
// Mock the auth API so AuthProvider hydrates without network.
|
||||||
|
vi.mock('@/api/auth', () => ({
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
fetchMe: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetchMe } from '@/api/auth';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/admin']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<div>LOGIN PAGE</div>} />
|
||||||
|
<Route path="/engagements" element={<div>ENGAGEMENTS</div>} />
|
||||||
|
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||||
|
<Route path="/admin" element={<div>ADMIN AREA</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
<ToastViewport />
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProtectedRoute', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.mocked(fetchMe).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects unauthenticated users to /login', async () => {
|
||||||
|
// No token → unauthenticated, no /me call.
|
||||||
|
setup();
|
||||||
|
expect(await screen.findByText('LOGIN PAGE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admins reach the admin page', async () => {
|
||||||
|
setToken('fake-token');
|
||||||
|
vi.mocked(fetchMe).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: 'alice',
|
||||||
|
role: 'admin',
|
||||||
|
created_at: '2026-01-01',
|
||||||
|
});
|
||||||
|
setup();
|
||||||
|
expect(await screen.findByText('ADMIN AREA')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-admins get redirected and see an access denied toast', async () => {
|
||||||
|
setToken('fake-token');
|
||||||
|
vi.mocked(fetchMe).mockResolvedValue({
|
||||||
|
id: 2,
|
||||||
|
username: 'bob',
|
||||||
|
role: 'soc',
|
||||||
|
created_at: '2026-01-01',
|
||||||
|
});
|
||||||
|
setup();
|
||||||
|
expect(await screen.findByText('ENGAGEMENTS')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('toast')).toHaveTextContent('Accès refusé');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
frontend/tests/StatusBadge.test.tsx
Normal file
12
frontend/tests/StatusBadge.test.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
|
||||||
|
describe('StatusBadge', () => {
|
||||||
|
it.each(['planned', 'active', 'closed'] as const)('renders %s label and data attr', (status) => {
|
||||||
|
render(<StatusBadge status={status} />);
|
||||||
|
const badge = screen.getByTestId('status-badge');
|
||||||
|
expect(badge).toHaveAttribute('data-status', status);
|
||||||
|
expect(badge.textContent?.toLowerCase()).toBe(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
frontend/tests/Toast.test.tsx
Normal file
62
frontend/tests/Toast.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ToastProvider, useToast } from '@/hooks/useToast';
|
||||||
|
import { ToastViewport } from '@/components/Toast';
|
||||||
|
|
||||||
|
function Pusher() {
|
||||||
|
const { push } = useToast();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => push('Session expirée', 'error')}>Push session</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Toast', () => {
|
||||||
|
it('renders pushed toasts and lets the user dismiss them manually', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<Pusher />
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /push session/i }));
|
||||||
|
const toast = await screen.findByTestId('toast');
|
||||||
|
expect(toast).toHaveTextContent('Session expirée');
|
||||||
|
expect(toast).toHaveAttribute('data-kind', 'error');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('toast')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-dismisses after the timeout', async () => {
|
||||||
|
// Override the 4s default by polling the DOM up to 6s. Real timers keep
|
||||||
|
// user-event happy; the toast hook clears itself via setTimeout.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<Pusher />
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /push session/i }));
|
||||||
|
expect(await screen.findByTestId('toast')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.queryByTestId('toast')).toBeNull();
|
||||||
|
},
|
||||||
|
{ timeout: 6000 },
|
||||||
|
);
|
||||||
|
// Quiet act() warning by flushing any pending state.
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
106
frontend/tests/UsersAdminPage.test.tsx
Normal file
106
frontend/tests/UsersAdminPage.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||||
|
import { renderWithProviders } from './utils';
|
||||||
|
import type { User } from '@/api/types';
|
||||||
|
|
||||||
|
// Mock useAuth so the page sees a logged-in admin without hydrating from network.
|
||||||
|
vi.mock('@/hooks/useAuth', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' } as User,
|
||||||
|
status: 'authenticated',
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
isAdmin: true,
|
||||||
|
isRedteam: false,
|
||||||
|
isSoc: false,
|
||||||
|
canEditEngagements: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const USERS: User[] = [
|
||||||
|
{ id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
|
||||||
|
{ id: 2, username: 'bob', role: 'redteam', created_at: '2026-02-01' },
|
||||||
|
{ id: 3, username: 'carol', role: 'soc', created_at: '2026-03-01' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('UsersAdminPage', () => {
|
||||||
|
let mock: MockAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock = new MockAdapter(apiClient);
|
||||||
|
mock.onGet('/users').reply(200, USERS);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the list of users from the API', async () => {
|
||||||
|
renderWithProviders(<UsersAdminPage />);
|
||||||
|
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('carol')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a user via POST /users and refreshes the list', async () => {
|
||||||
|
const newUser: User = { id: 4, username: 'dan', role: 'soc', created_at: '2026-04-01' };
|
||||||
|
const postSpy = vi.fn().mockReturnValue([201, newUser]);
|
||||||
|
mock.onPost('/users').reply((config) => postSpy(JSON.parse(config.data)));
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<UsersAdminPage />);
|
||||||
|
|
||||||
|
await screen.findByText('alice');
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/^username/i), 'dan');
|
||||||
|
await user.type(screen.getByLabelText(/^password/i), 'sup3rs4fe!');
|
||||||
|
// role default is 'redteam'; switch to 'soc' to match newUser
|
||||||
|
await user.selectOptions(screen.getByLabelText(/^role/i), 'soc');
|
||||||
|
|
||||||
|
// After POST, hooks invalidate and the list refetches → return the new list
|
||||||
|
mock.onGet('/users').reply(200, [...USERS, newUser]);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(postSpy).toHaveBeenCalledWith({
|
||||||
|
username: 'dan',
|
||||||
|
password: 'sup3rs4fe!',
|
||||||
|
role: 'soc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(await screen.findByText('dan')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the password reset form for the row that was clicked (fragment-key regression guard)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<UsersAdminPage />);
|
||||||
|
|
||||||
|
await screen.findByText('bob');
|
||||||
|
|
||||||
|
// The "Reset password" button for bob lives in bob's row.
|
||||||
|
const bobRow = screen.getByText('bob').closest('tr');
|
||||||
|
expect(bobRow).not.toBeNull();
|
||||||
|
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /reset password/i }));
|
||||||
|
|
||||||
|
// The reset form for bob (and bob only) must appear.
|
||||||
|
expect(await screen.findByLabelText(/new password for bob/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText(/new password for carol/i)).toBeNull();
|
||||||
|
expect(screen.queryByLabelText(/new password for alice/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the delete button on the current user own row', async () => {
|
||||||
|
renderWithProviders(<UsersAdminPage />);
|
||||||
|
await screen.findByText('alice');
|
||||||
|
|
||||||
|
const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement;
|
||||||
|
const bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
|
||||||
|
|
||||||
|
expect(within(aliceRow).getByRole('button', { name: /delete/i })).toBeDisabled();
|
||||||
|
expect(within(bobRow).getByRole('button', { name: /delete/i })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
frontend/tests/apiClient.test.tsx
Normal file
57
frontend/tests/apiClient.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { apiClient, getToken, registerUnauthorizedHandler, setToken } from '@/api/client';
|
||||||
|
|
||||||
|
describe('apiClient interceptors', () => {
|
||||||
|
let mock: MockAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock = new MockAdapter(apiClient);
|
||||||
|
setToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
setToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches Bearer token from storage', async () => {
|
||||||
|
setToken('abc123');
|
||||||
|
mock.onGet('/auth/me').reply((config) => {
|
||||||
|
expect(config.headers?.Authorization).toBe('Bearer abc123');
|
||||||
|
return [200, { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const resp = await apiClient.get('/auth/me');
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purges token and calls the registered handler on 401', async () => {
|
||||||
|
setToken('expired');
|
||||||
|
const handler = vi.fn();
|
||||||
|
registerUnauthorizedHandler(handler);
|
||||||
|
|
||||||
|
mock.onGet('/engagements').reply(401, { error: 'token expired' });
|
||||||
|
|
||||||
|
await expect(apiClient.get('/engagements')).rejects.toMatchObject({
|
||||||
|
response: { status: 401 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Reset to avoid leaking into other tests.
|
||||||
|
registerUnauthorizedHandler(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the token intact on non-401 errors', async () => {
|
||||||
|
setToken('still-valid');
|
||||||
|
mock.onGet('/users').reply(403, { error: 'forbidden' });
|
||||||
|
|
||||||
|
await expect(apiClient.get('/users')).rejects.toMatchObject({
|
||||||
|
response: { status: 403 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getToken()).toBe('still-valid');
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/tests/states.test.tsx
Normal file
43
frontend/tests/states.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
|
|
||||||
|
describe('LoadingState', () => {
|
||||||
|
it('shows the default label', () => {
|
||||||
|
render(<LoadingState />);
|
||||||
|
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a custom label', () => {
|
||||||
|
render(<LoadingState label="Fetching things…" />);
|
||||||
|
expect(screen.getByTestId('loading-state')).toHaveTextContent('Fetching things…');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ErrorState', () => {
|
||||||
|
it('renders message and triggers retry', async () => {
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
render(<ErrorState message="Boom" onRetry={onRetry} />);
|
||||||
|
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits retry button when no handler given', () => {
|
||||||
|
render(<ErrorState message="Boom" />);
|
||||||
|
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders title, description and action', () => {
|
||||||
|
render(<EmptyState title="Nothing here" description="Add one" action={<button>Create</button>} />);
|
||||||
|
const node = screen.getByTestId('empty-state');
|
||||||
|
expect(node).toHaveTextContent('Nothing here');
|
||||||
|
expect(node).toHaveTextContent('Add one');
|
||||||
|
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/tests/utils.tsx
Normal file
33
frontend/tests/utils.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, type RenderOptions } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import { ToastProvider } from '@/hooks/useToast';
|
||||||
|
|
||||||
|
interface WrapperOptions {
|
||||||
|
routerProps?: MemoryRouterProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWithProviders(
|
||||||
|
ui: ReactElement,
|
||||||
|
{ routerProps, ...rtlOptions }: WrapperOptions & Omit<RenderOptions, 'wrapper'> = {},
|
||||||
|
) {
|
||||||
|
const client = makeQueryClient();
|
||||||
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter {...routerProps}>
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { ...render(ui, { wrapper: Wrapper, ...rtlOptions }), client };
|
||||||
|
}
|
||||||
29
frontend/tsconfig.json
Normal file
29
frontend/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client", "node", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vite.config.ts", "vitest.setup.ts"]
|
||||||
|
}
|
||||||
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './vitest.setup.ts',
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
1
frontend/vitest.setup.ts
Normal file
1
frontend/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
17
pyrightconfig.json
Normal file
17
pyrightconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"venvPath": "backend",
|
||||||
|
"venv": ".venv",
|
||||||
|
"extraPaths": ["."],
|
||||||
|
"include": ["backend"],
|
||||||
|
"exclude": [
|
||||||
|
"**/__pycache__",
|
||||||
|
"**/.mypy_cache",
|
||||||
|
"**/.ruff_cache",
|
||||||
|
"**/.pytest_cache",
|
||||||
|
"**/node_modules",
|
||||||
|
".claude",
|
||||||
|
"frontend/dist",
|
||||||
|
"backend/.venv"
|
||||||
|
],
|
||||||
|
"reportMissingTypeStubs": false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user