Compare commits

...

2 Commits

Author SHA1 Message Date
7fc79cc5a6 Merge pull request 'feat: sprint 1 — auth + CRUD engagements' (#2) from sprint/1-auth-engagements into main
Reviewed-on: #2
2026-05-26 07:48:20 +00:00
Knacky
5104f7c429 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>
2026-05-26 09:37:53 +02:00
95 changed files with 13801 additions and 5 deletions

23
.env.example Normal file
View 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
View File

@@ -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

View File

@@ -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
View 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
View 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
View File

71
backend/app/__init__.py Normal file
View 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

View 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
View 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

View 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
View 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

View 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",
]

View 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

View 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
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,6 @@
"""Shared Flask extension instances."""
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
migrate = Migrate()

View 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"]

View 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}>"

View 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})>"

View 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,
}

View File

@@ -0,0 +1 @@
Generic single-database configuration for Flask-Migrate / Alembic.

View 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
View 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()

View 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"}

View 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
View 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
View 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

View File

92
backend/tests/conftest.py Normal file
View 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
View 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"

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'] },
},
],
});

View 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
View 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);
});
});

View 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();
});
});

View 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();
});
});

View 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);
});
});

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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
View 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
View 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;
}

View 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;
}

View 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
View 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
View 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}`);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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 }),
});
}

View 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;
}

View 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
View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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
View 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;

View 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é');
});
});
});

View 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);
});
});

View 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);
});

View 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();
});
});

View 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');
});
});

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

17
pyrightconfig.json Normal file
View 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
}