Compare commits
2 Commits
be266d4879
...
7fc79cc5a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc79cc5a6 | |||
|
|
5104f7c429 |
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# Mimic — environment variables
|
||||
# Copy this file to `.env` and fill in real values before `make start`.
|
||||
# `.env` is gitignored — never commit secrets.
|
||||
|
||||
# --- Required ---
|
||||
|
||||
# JWT signing secret. Generate with: openssl rand -hex 32
|
||||
# The backend will refuse to start in production if this is unset.
|
||||
MIMIC_JWT_SECRET=replace-me-with-a-strong-random-secret
|
||||
|
||||
# --- Optional (defaults shown) ---
|
||||
|
||||
# Path where SQLite stores the database, INSIDE the container.
|
||||
# Must live under the /data volume mount to persist across `make restart`.
|
||||
MIMIC_DB_PATH=/data/mimic.sqlite
|
||||
|
||||
# Port the Flask app listens on inside the container.
|
||||
# To expose on a different host port, override PORT when calling make:
|
||||
# make start PORT=8080
|
||||
MIMIC_PORT=5000
|
||||
|
||||
# --- Sprint 2+ (not used in Sprint 1) ---
|
||||
# MITRE_BUNDLE_PATH=/app/backend/data/mitre/enterprise-attack.json
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -89,3 +89,6 @@ Thumbs.db
|
||||
|
||||
# --- MITRE bundle if huge (kept by default — uncomment to ignore) ---
|
||||
# backend/data/mitre/enterprise-attack.json
|
||||
|
||||
# TypeScript build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -6,14 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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`.
|
||||
### Added — Sprint 1 (Auth + CRUD Engagement)
|
||||
|
||||
**Backend** (Flask + SQLAlchemy + SQLite, 63 pytest passing)
|
||||
- `User` model with `admin / redteam / soc` enum, argon2 password hashing.
|
||||
- `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
|
||||
- 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
|
||||
- _none_
|
||||
|
||||
---
|
||||
|
||||
## [Sprint 0] — Bootstrap (merged 2026-05-26)
|
||||
|
||||
### Added
|
||||
- Initial `SPEC.md` covering project scope, simulation model, workflow, stack, and agent team.
|
||||
- Technical decisions section in `SPEC.md`: 3-role auth (admin/redteam/soc), JWT Bearer, single-container Flask+React, local MITRE STIX bundle, minimal Engagement model, admin bootstrap via Makefile target.
|
||||
- Sub-agent definitions under `.claude/agents/` for backend-builder, frontend-builder, spec-reviewer (project override of the built-in, covers plan-vs-spec and code-vs-spec), code-reviewer, test-verifier, devil-advocate.
|
||||
- Project tracking scaffold: `tasks/todo.md`, `tasks/lessons.md`, `CHANGELOG.md`, `.gitignore`.
|
||||
|
||||
50
Makefile
Normal file
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
||||
PORT ?= 5000
|
||||
IMAGE ?= mimic:latest
|
||||
CONTAINER ?= mimic
|
||||
VOLUME ?= mimic-data
|
||||
|
||||
.PHONY: build start stop restart update logs create-admin update-mitre test-backend test-frontend test-e2e clean
|
||||
|
||||
build:
|
||||
docker build -f docker/Dockerfile -t $(IMAGE) .
|
||||
|
||||
start:
|
||||
docker run -d --name $(CONTAINER) -p $(PORT):5000 -v $(VOLUME):/data --env-file .env $(IMAGE)
|
||||
|
||||
stop:
|
||||
docker stop $(CONTAINER) && docker rm $(CONTAINER)
|
||||
|
||||
restart:
|
||||
$(MAKE) stop && $(MAKE) start
|
||||
|
||||
update:
|
||||
git pull && $(MAKE) build && $(MAKE) restart
|
||||
|
||||
logs:
|
||||
docker logs -f $(CONTAINER)
|
||||
|
||||
create-admin:
|
||||
ifndef USER
|
||||
$(error USER is required: make create-admin USER=alice PASS=p4ssw0rd)
|
||||
endif
|
||||
ifndef PASS
|
||||
$(error PASS is required: make create-admin USER=alice PASS=p4ssw0rd)
|
||||
endif
|
||||
docker exec $(CONTAINER) flask create-admin $(USER) $(PASS)
|
||||
|
||||
update-mitre:
|
||||
@echo "MITRE update: Sprint 2+"
|
||||
|
||||
test-backend:
|
||||
docker exec $(CONTAINER) pytest -q backend/tests/
|
||||
|
||||
test-frontend:
|
||||
cd frontend && npm run test -- --run
|
||||
|
||||
test-e2e:
|
||||
cd e2e && npx playwright test
|
||||
|
||||
clean:
|
||||
-docker rm -f $(CONTAINER) 2>/dev/null
|
||||
-docker volume rm $(VOLUME) 2>/dev/null
|
||||
rm -rf backend/__pycache__ frontend/node_modules frontend/dist
|
||||
156
README.md
Normal file
156
README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Mimic
|
||||
|
||||
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
|
||||
|
||||
> Status: **Sprint 1 — Auth + CRUD Engagement**. Simulation workflow and MITRE TTP autocomplete arrive in Sprint 2+.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Docker (or Podman) + GNU Make. Linux/macOS host.
|
||||
|
||||
```bash
|
||||
# 1. Configure secrets
|
||||
cp .env.example .env
|
||||
# Edit .env and set MIMIC_JWT_SECRET to a strong random value:
|
||||
# sed -i "s|replace-me-with-a-strong-random-secret|$(openssl rand -hex 32)|" .env
|
||||
|
||||
# 2. Build and start the container
|
||||
make build
|
||||
make start
|
||||
|
||||
# 3. Bootstrap the first admin (run once, the container must be up)
|
||||
make create-admin USER=alice PASS=changeme8
|
||||
|
||||
# 4. Open the UI
|
||||
xdg-open http://localhost:5000 # Linux
|
||||
# or visit http://localhost:5000 manually
|
||||
```
|
||||
|
||||
Log in with the credentials from step 3. The admin can create additional users (redteam / soc) from `/admin/users`.
|
||||
|
||||
To stop or restart:
|
||||
|
||||
```bash
|
||||
make stop
|
||||
make restart # stop + start, preserves the SQLite volume
|
||||
make logs # tail container logs
|
||||
```
|
||||
|
||||
To override the host port:
|
||||
|
||||
```bash
|
||||
make start PORT=8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Single-container deployment. A multistage Dockerfile builds the Vite frontend, then copies the static assets into the Flask backend image so Flask serves both the API (under `/api/*`) and the SPA (everything else).
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────┐
|
||||
│ Container: mimic:latest │
|
||||
│ │
|
||||
│ Flask (Python 3.12) │
|
||||
│ ├── /api/* ── blueprints (auth, users, │
|
||||
│ │ engagements) │
|
||||
│ └── / ── SPA fallback → React build │
|
||||
│ │
|
||||
│ SQLAlchemy ── SQLite at /data/mimic.sqlite │
|
||||
│ (volume: mimic-data)│
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Auth**: JWT Bearer tokens (HS256, 60-min TTL). Stateless — no refresh tokens, no server-side session.
|
||||
- **Roles**: `admin` (super-user, manages users + engagements), `redteam` (CRUD engagements + simulations), `soc` (read engagements; will write the SOC half of simulations in Sprint 2).
|
||||
- **Password hashing**: argon2 via `argon2-cffi`.
|
||||
- **Migrations**: Alembic, applied automatically by the container entrypoint (`flask db upgrade && flask run`).
|
||||
|
||||
See [`SPEC.md`](SPEC.md) § "Décisions techniques" for the full architecture rationale and [`DESIGN.md`](DESIGN.md) for the UI design system.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
mimic/
|
||||
├── backend/ # Flask app, SQLAlchemy models, Alembic migrations, pytest suite
|
||||
├── frontend/ # Vite + React + Tailwind + TanStack Query, Vitest suite
|
||||
├── e2e/ # Playwright acceptance tests (one spec per user story)
|
||||
├── docker/ # Dockerfile (multistage) + entrypoint.sh
|
||||
├── tasks/ # Sprint plans (tasks/todo.md) and lessons (tasks/lessons.md)
|
||||
├── .claude/agents/ # Sub-agent definitions for the team (read-only at runtime)
|
||||
├── Makefile # all operational entry points
|
||||
├── SPEC.md # functional + technical spec
|
||||
├── DESIGN.md # UI design system (palette, typography, components)
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Make targets
|
||||
|
||||
| Target | What it does |
|
||||
|---|---|
|
||||
| `make build` | Build the `mimic:latest` Docker image (multistage: Node → Python) |
|
||||
| `make start` | Start the container (port from `PORT`, default 5000; mounts `mimic-data` volume) |
|
||||
| `make stop` | Stop and remove the container |
|
||||
| `make restart` | `make stop && make start` — preserves the SQLite volume |
|
||||
| `make update` | `git pull && make build && make restart` |
|
||||
| `make logs` | `docker logs -f mimic` |
|
||||
| `make create-admin USER=… PASS=…` | Run `flask create-admin` inside the container |
|
||||
| `make update-mitre` | No-op placeholder — Sprint 2+ will fetch the MITRE STIX bundle |
|
||||
| `make test-backend` | `pytest -q` inside the container |
|
||||
| `make test-frontend` | `npm run test -- --run` in `frontend/` |
|
||||
| `make test-e2e` | Playwright acceptance suite (container must be running) |
|
||||
| `make clean` | Remove container + volume + Python/Node caches |
|
||||
|
||||
---
|
||||
|
||||
## Development (without Docker)
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
export MIMIC_JWT_SECRET=dev-secret
|
||||
export MIMIC_DB_PATH=./mimic.sqlite
|
||||
flask --app backend.app:create_app db upgrade
|
||||
flask --app backend.app:create_app run --port 5000
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 with /api proxied to :5000
|
||||
```
|
||||
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
cd backend && pytest -q # 63 tests
|
||||
cd frontend && npm run test -- --run # 20 tests
|
||||
cd e2e && npx playwright test # 36 tests (needs container up)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`SPEC.md`](SPEC.md) — functional spec, technical decisions, agent team
|
||||
- [`DESIGN.md`](DESIGN.md) — UI design system
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) — sprint-by-sprint changes
|
||||
- [`tasks/todo.md`](tasks/todo.md) — current sprint plan
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Internal project — not yet open-sourced.
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
71
backend/app/__init__.py
Normal file
71
backend/app/__init__.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Flask application factory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
|
||||
from backend.app.api import auth_bp, engagements_bp, users_bp
|
||||
from backend.app.cli import register_cli
|
||||
from backend.app.config import Config, TestConfig
|
||||
from backend.app.errors import register_error_handlers
|
||||
from backend.app.extensions import db, migrate
|
||||
|
||||
|
||||
def create_app(config_object: object | None = None) -> Flask:
|
||||
"""Application factory.
|
||||
|
||||
`config_object` is an *instance* (not a class) of Config or a subclass.
|
||||
If None, picks TestConfig when MIMIC_TESTING=1, otherwise Config.
|
||||
"""
|
||||
static_folder = str(Path(__file__).parent / "static")
|
||||
app = Flask(__name__, static_folder=static_folder, static_url_path="/static")
|
||||
|
||||
if config_object is None:
|
||||
config_object = TestConfig() if os.environ.get("MIMIC_TESTING") == "1" else Config()
|
||||
app.config.from_object(config_object)
|
||||
|
||||
db.init_app(app)
|
||||
migrations_dir = str(Path(__file__).parent.parent / "migrations")
|
||||
migrate.init_app(app, db, directory=migrations_dir)
|
||||
|
||||
# Ensure models are imported so Alembic/metadata see them.
|
||||
from backend.app import models # noqa: F401
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(engagements_bp)
|
||||
|
||||
register_error_handlers(app)
|
||||
register_cli(app)
|
||||
|
||||
static_root = Path(static_folder)
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}, 200
|
||||
|
||||
# Serve the built frontend (Vite output copied into app/static at image build time).
|
||||
@app.get("/")
|
||||
def index():
|
||||
index_path = static_root / "index.html"
|
||||
if index_path.exists():
|
||||
return send_from_directory(static_folder, "index.html")
|
||||
return {"status": "ok", "message": "Mimic API running. Frontend not built."}, 200
|
||||
|
||||
@app.get("/<path:path>")
|
||||
def spa_fallback(path: str):
|
||||
# Unknown /api/* paths must stay JSON 404 — never shadowed by index.html.
|
||||
if path.startswith("api/"):
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
# Serve static assets if present; otherwise hand back index.html for client routing.
|
||||
candidate = static_root / path
|
||||
if candidate.is_file():
|
||||
return send_from_directory(static_folder, path)
|
||||
index_path = static_root / "index.html"
|
||||
if index_path.exists():
|
||||
return send_from_directory(static_folder, "index.html")
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
return app
|
||||
6
backend/app/api/__init__.py
Normal file
6
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""API blueprints."""
|
||||
from backend.app.api.auth import auth_bp
|
||||
from backend.app.api.engagements import engagements_bp
|
||||
from backend.app.api.users import users_bp
|
||||
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp"]
|
||||
46
backend/app/api/auth.py
Normal file
46
backend/app/api/auth.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Auth endpoints: login, logout, me."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from backend.app.auth import encode_token, login_required, verify_password
|
||||
from backend.app.models import User
|
||||
from backend.app.serializers import serialize_user
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
|
||||
@auth_bp.post("/login")
|
||||
def login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
generic_error = (jsonify({"error": "Invalid credentials"}), 401)
|
||||
if not username or not password:
|
||||
return generic_error
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None or not verify_password(user.password_hash, password):
|
||||
return generic_error
|
||||
|
||||
token = encode_token(user.id, user.role.value)
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": token,
|
||||
"user": {"id": user.id, "username": user.username, "role": user.role.value},
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
# V1: stateless JWT — client discards the token. No server-side blacklist.
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@auth_bp.get("/me")
|
||||
@login_required
|
||||
def me():
|
||||
return jsonify(serialize_user(g.current_user)), 200
|
||||
158
backend/app/api/engagements.py
Normal file
158
backend/app/api/engagements.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Engagement CRUD endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from backend.app.auth import login_required, role_required
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import Engagement, EngagementStatus
|
||||
from backend.app.serializers import serialize_engagement
|
||||
|
||||
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
|
||||
|
||||
|
||||
def _parse_date(value: object) -> date | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_status(value: object) -> EngagementStatus | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return EngagementStatus(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@engagements_bp.get("")
|
||||
@login_required
|
||||
def list_engagements():
|
||||
items = Engagement.query.order_by(Engagement.id.asc()).all()
|
||||
return jsonify([serialize_engagement(e) for e in items]), 200
|
||||
|
||||
|
||||
@engagements_bp.post("")
|
||||
@role_required("admin", "redteam")
|
||||
def create_engagement():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
|
||||
start_raw = data.get("start_date")
|
||||
start_date = _parse_date(start_raw) if start_raw else None
|
||||
if start_date is None:
|
||||
return jsonify({"error": "start_date is required (YYYY-MM-DD)"}), 400
|
||||
|
||||
end_raw = data.get("end_date")
|
||||
end_date: date | None = None
|
||||
if end_raw:
|
||||
end_date = _parse_date(end_raw)
|
||||
if end_date is None:
|
||||
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
||||
if end_date < start_date:
|
||||
return jsonify({"error": "end_date must be >= start_date"}), 400
|
||||
|
||||
status = EngagementStatus.PLANNED
|
||||
if "status" in data and data.get("status") is not None:
|
||||
parsed = _parse_status(data.get("status"))
|
||||
if parsed is None:
|
||||
return (
|
||||
jsonify({"error": "status must be one of: planned, active, closed"}),
|
||||
400,
|
||||
)
|
||||
status = parsed
|
||||
|
||||
engagement = Engagement(
|
||||
name=name,
|
||||
description=data.get("description"),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
status=status,
|
||||
created_by_id=g.current_user.id,
|
||||
)
|
||||
db.session.add(engagement)
|
||||
db.session.commit()
|
||||
return jsonify(serialize_engagement(engagement)), 201
|
||||
|
||||
|
||||
@engagements_bp.get("/<int:engagement_id>")
|
||||
@login_required
|
||||
def get_engagement(engagement_id: int):
|
||||
engagement = db.session.get(Engagement, engagement_id)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
return jsonify(serialize_engagement(engagement)), 200
|
||||
|
||||
|
||||
@engagements_bp.patch("/<int:engagement_id>")
|
||||
@role_required("admin", "redteam")
|
||||
def update_engagement(engagement_id: int):
|
||||
engagement = db.session.get(Engagement, engagement_id)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
if "name" in data:
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name must not be empty"}), 400
|
||||
engagement.name = name
|
||||
|
||||
if "description" in data:
|
||||
engagement.description = data.get("description")
|
||||
|
||||
new_start = engagement.start_date
|
||||
if "start_date" in data:
|
||||
parsed = _parse_date(data.get("start_date"))
|
||||
if parsed is None:
|
||||
return jsonify({"error": "start_date must be YYYY-MM-DD"}), 400
|
||||
new_start = parsed
|
||||
|
||||
new_end = engagement.end_date
|
||||
if "end_date" in data:
|
||||
if data.get("end_date") in (None, ""):
|
||||
new_end = None
|
||||
else:
|
||||
parsed = _parse_date(data.get("end_date"))
|
||||
if parsed is None:
|
||||
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
||||
new_end = parsed
|
||||
|
||||
if new_end is not None and new_end < new_start:
|
||||
return jsonify({"error": "end_date must be >= start_date"}), 400
|
||||
|
||||
engagement.start_date = new_start
|
||||
engagement.end_date = new_end
|
||||
|
||||
if "status" in data:
|
||||
parsed_status = _parse_status((data.get("status") or "").strip())
|
||||
if parsed_status is None:
|
||||
return (
|
||||
jsonify({"error": "status must be one of: planned, active, closed"}),
|
||||
400,
|
||||
)
|
||||
engagement.status = parsed_status
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(serialize_engagement(engagement)), 200
|
||||
|
||||
|
||||
@engagements_bp.delete("/<int:engagement_id>")
|
||||
@role_required("admin", "redteam")
|
||||
def delete_engagement(engagement_id: int):
|
||||
engagement = db.session.get(Engagement, engagement_id)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
db.session.delete(engagement)
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
106
backend/app/api/users.py
Normal file
106
backend/app/api/users.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""User management endpoints (admin only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from backend.app.auth import hash_password, role_required
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User, UserRole
|
||||
from backend.app.serializers import serialize_user
|
||||
|
||||
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
|
||||
|
||||
|
||||
def _parse_role(value: object) -> UserRole | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return UserRole(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@users_bp.get("")
|
||||
@role_required("admin")
|
||||
def list_users():
|
||||
users = User.query.order_by(User.id.asc()).all()
|
||||
return jsonify([serialize_user(u) for u in users]), 200
|
||||
|
||||
|
||||
@users_bp.post("")
|
||||
@role_required("admin")
|
||||
def create_user():
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
role_raw = (data.get("role") or "").strip()
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "username is required"}), 400
|
||||
|
||||
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||
if len(password) < min_len:
|
||||
return jsonify({"error": f"password must be at least {min_len} characters"}), 400
|
||||
|
||||
role = _parse_role(role_raw)
|
||||
if role is None:
|
||||
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
|
||||
|
||||
if User.query.filter_by(username=username).first() is not None:
|
||||
return jsonify({"error": "username already exists"}), 400
|
||||
|
||||
user = User(username=username, password_hash=hash_password(password), role=role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return jsonify(serialize_user(user)), 201
|
||||
|
||||
|
||||
@users_bp.patch("/<int:user_id>")
|
||||
@role_required("admin")
|
||||
def update_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
if "role" in data:
|
||||
new_role = _parse_role((data.get("role") or "").strip())
|
||||
if new_role is None:
|
||||
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
|
||||
# Refuse to demote the last admin.
|
||||
if user.role == UserRole.ADMIN and new_role != UserRole.ADMIN:
|
||||
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
|
||||
if admin_count <= 1:
|
||||
return jsonify({"error": "Cannot demote the last admin"}), 409
|
||||
user.role = new_role
|
||||
|
||||
if "password" in data:
|
||||
password = data.get("password") or ""
|
||||
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||
if len(password) < min_len:
|
||||
return (
|
||||
jsonify({"error": f"password must be at least {min_len} characters"}),
|
||||
400,
|
||||
)
|
||||
user.password_hash = hash_password(password)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(serialize_user(user)), 200
|
||||
|
||||
|
||||
@users_bp.delete("/<int:user_id>")
|
||||
@role_required("admin")
|
||||
def delete_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
if user.role == UserRole.ADMIN:
|
||||
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
|
||||
if admin_count <= 1:
|
||||
return jsonify({"error": "Cannot delete the last admin"}), 409
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
13
backend/app/auth/__init__.py
Normal file
13
backend/app/auth/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Auth helpers (JWT, hashing, decorators)."""
|
||||
from backend.app.auth.decorators import login_required, role_required
|
||||
from backend.app.auth.hashing import hash_password, verify_password
|
||||
from backend.app.auth.jwt import decode_token, encode_token
|
||||
|
||||
__all__ = [
|
||||
"hash_password",
|
||||
"verify_password",
|
||||
"encode_token",
|
||||
"decode_token",
|
||||
"login_required",
|
||||
"role_required",
|
||||
]
|
||||
67
backend/app/auth/decorators.py
Normal file
67
backend/app/auth/decorators.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Auth decorators that gate routes on a valid JWT and (optionally) role."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from flask import g, jsonify, request
|
||||
|
||||
from backend.app.auth.jwt import decode_token
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User
|
||||
|
||||
|
||||
def _extract_token() -> str | None:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return None
|
||||
return header.removeprefix("Bearer ").strip() or None
|
||||
|
||||
|
||||
def login_required(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Require a valid JWT. Populates `g.current_user` with the User row."""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
token = _extract_token()
|
||||
if not token:
|
||||
return jsonify({"error": "Missing or invalid Authorization header"}), 401
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({"error": "Token expired"}), 401
|
||||
except jwt.PyJWTError:
|
||||
return jsonify({"error": "Invalid token"}), 401
|
||||
|
||||
try:
|
||||
user_id = int(payload["sub"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid token payload"}), 401
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
return jsonify({"error": "User no longer exists"}), 401
|
||||
|
||||
g.current_user = user
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def role_required(*roles: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
"""Require the current user to hold one of the given role names."""
|
||||
|
||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(fn)
|
||||
@login_required
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
user = g.current_user
|
||||
if user.role.value not in roles:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
23
backend/app/auth/hashing.py
Normal file
23
backend/app/auth/hashing.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Password hashing using argon2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return an argon2 hash of `password`."""
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
"""Return True iff `password` matches `password_hash`."""
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception:
|
||||
# Malformed hash or other argon2 error — treat as auth failure.
|
||||
return False
|
||||
34
backend/app/auth/jwt.py
Normal file
34
backend/app/auth/jwt.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""JWT encode/decode helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def encode_token(user_id: int, role: str) -> str:
|
||||
"""Return a signed JWT for the given user."""
|
||||
now = datetime.now(UTC)
|
||||
exp_minutes = current_app.config["JWT_EXP_MINUTES"]
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=exp_minutes)).timestamp()),
|
||||
}
|
||||
return jwt.encode(
|
||||
payload,
|
||||
current_app.config["JWT_SECRET"],
|
||||
algorithm=current_app.config["JWT_ALGORITHM"],
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""Decode + validate a JWT. Raises jwt.PyJWTError on failure."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
current_app.config["JWT_SECRET"],
|
||||
algorithms=[current_app.config["JWT_ALGORITHM"]],
|
||||
)
|
||||
38
backend/app/cli.py
Normal file
38
backend/app/cli.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Flask CLI commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
from flask import Flask, current_app
|
||||
|
||||
from backend.app.auth import hash_password
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User, UserRole
|
||||
|
||||
|
||||
def register_cli(app: Flask) -> None:
|
||||
@app.cli.command("create-admin")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
def create_admin(username: str, password: str) -> None:
|
||||
"""Create an admin user. Used to bootstrap the first account."""
|
||||
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||
if len(password) < min_len:
|
||||
click.echo(
|
||||
f"Error: password must be at least {min_len} characters.", err=True
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if User.query.filter_by(username=username).first() is not None:
|
||||
click.echo(f"Error: username '{username}' already exists.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
click.echo(f"Admin user '{username}' created (id={user.id}).")
|
||||
35
backend/app/config.py
Normal file
35
backend/app/config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Application configuration loaded from environment variables."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration. Reads from env vars; fails loud on missing secrets."""
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXP_MINUTES = 60
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
def __init__(self) -> None:
|
||||
db_path = os.environ.get("MIMIC_DB_PATH", "/data/mimic.sqlite")
|
||||
self.SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
|
||||
|
||||
jwt_secret = os.environ.get("MIMIC_JWT_SECRET")
|
||||
if not jwt_secret:
|
||||
raise RuntimeError(
|
||||
"MIMIC_JWT_SECRET environment variable is required but not set."
|
||||
)
|
||||
self.JWT_SECRET = jwt_secret
|
||||
|
||||
|
||||
class TestConfig(Config):
|
||||
"""Config for pytest. Uses in-memory SQLite + fixed JWT secret."""
|
||||
|
||||
TESTING = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Bypass parent's env requirement; tests inject their own secret.
|
||||
self.SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||
self.JWT_SECRET = "test-secret-do-not-use-in-prod"
|
||||
21
backend/app/errors.py
Normal file
21
backend/app/errors.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Uniform JSON error handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask) -> None:
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(exc: HTTPException):
|
||||
response = jsonify({"error": exc.description})
|
||||
response.status_code = exc.code or 500
|
||||
return response
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_404(_exc):
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def handle_405(_exc):
|
||||
return jsonify({"error": "Method not allowed"}), 405
|
||||
6
backend/app/extensions.py
Normal file
6
backend/app/extensions.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Shared Flask extension instances."""
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""SQLAlchemy models."""
|
||||
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||
from backend.app.models.user import User, UserRole
|
||||
|
||||
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus"]
|
||||
39
backend/app/models/engagement.py
Normal file
39
backend/app/models/engagement.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Engagement model."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend.app.extensions import db
|
||||
|
||||
|
||||
class EngagementStatus(str, enum.Enum):
|
||||
PLANNED = "planned"
|
||||
ACTIVE = "active"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class Engagement(db.Model): # type: ignore[name-defined]
|
||||
__tablename__ = "engagements"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
start_date = db.Column(db.Date, nullable=False)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
status = db.Column(
|
||||
db.Enum(EngagementStatus, name="engagement_status"),
|
||||
nullable=False,
|
||||
default=EngagementStatus.PLANNED,
|
||||
)
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
created_by_id = db.Column(
|
||||
db.Integer, db.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
created_by = db.relationship("User", backref="engagements", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Engagement {self.id} {self.name!r}>"
|
||||
28
backend/app/models/user.py
Normal file
28
backend/app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""User model."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend.app.extensions import db
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
REDTEAM = "redteam"
|
||||
SOC = "soc"
|
||||
|
||||
|
||||
class User(db.Model): # type: ignore[name-defined]
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.Enum(UserRole, name="user_role"), nullable=False)
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} ({self.role.value})>"
|
||||
34
backend/app/serializers.py
Normal file
34
backend/app/serializers.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""JSON serializers for API responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.app.models import Engagement, User
|
||||
|
||||
|
||||
def serialize_user(user: User) -> dict[str, Any]:
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_user_brief(user: User) -> dict[str, Any]:
|
||||
return {"id": user.id, "username": user.username}
|
||||
|
||||
|
||||
def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
|
||||
return {
|
||||
"id": engagement.id,
|
||||
"name": engagement.name,
|
||||
"description": engagement.description,
|
||||
"start_date": engagement.start_date.isoformat() if engagement.start_date else None,
|
||||
"end_date": engagement.end_date.isoformat() if engagement.end_date else None,
|
||||
"status": engagement.status.value,
|
||||
"created_at": engagement.created_at.isoformat() if engagement.created_at else None,
|
||||
"created_by": serialize_user_brief(engagement.created_by) # type: ignore[arg-type]
|
||||
if engagement.created_by
|
||||
else None,
|
||||
}
|
||||
1
backend/migrations/README
Normal file
1
backend/migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration for Flask-Migrate / Alembic.
|
||||
40
backend/migrations/alembic.ini
Normal file
40
backend/migrations/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
||||
# Alembic config. Most settings are injected at runtime by Flask-Migrate.
|
||||
[alembic]
|
||||
script_location = %(here)s
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
73
backend/migrations/env.py
Normal file
73
backend/migrations/env.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Alembic environment, wired to Flask-Migrate."""
|
||||
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from flask import current_app
|
||||
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger("alembic.env")
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# Flask-SQLAlchemy < 3.x
|
||||
return current_app.extensions["migrate"].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# Flask-SQLAlchemy >= 3.x
|
||||
return current_app.extensions["migrate"].db.engine
|
||||
|
||||
|
||||
def get_engine_url() -> str:
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace("%", "%%")
|
||||
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||
target_db = current_app.extensions["migrate"].db
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, "metadatas"):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
def process_revision_directives(context, revision, directives): # noqa: ANN001
|
||||
if getattr(config.cmd_opts, "autogenerate", False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info("No changes in schema detected.")
|
||||
|
||||
conf_args = current_app.extensions["migrate"].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
60
backend/migrations/versions/0001_initial_schema.py
Normal file
60
backend/migrations/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""initial schema: users + engagements
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-05-26 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("username", sa.String(length=64), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum("admin", "redteam", "soc", name="user_role"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("username", name="uq_users_username"),
|
||||
)
|
||||
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"engagements",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("start_date", sa.Date(), nullable=False),
|
||||
sa.Column("end_date", sa.Date(), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("planned", "active", "closed", name="engagement_status"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_by_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_id"], ["users.id"], ondelete="RESTRICT",
|
||||
name="fk_engagements_created_by_id_users",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("engagements")
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
sa.Enum(name="engagement_status").drop(op.get_bind(), checkfirst=True)
|
||||
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)
|
||||
28
backend/pyproject.toml
Normal file
28
backend/pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "mimic-backend"
|
||||
version = "0.1.0"
|
||||
description = "Mimic BAS backend (Flask API)"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
extend-exclude = ["migrations/versions"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = false
|
||||
ignore_missing_imports = true
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
disallow_untyped_defs = false
|
||||
no_implicit_optional = true
|
||||
exclude = ["migrations/", "tests/"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra"
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Flask==3.0.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.0.7
|
||||
PyJWT==2.9.0
|
||||
argon2-cffi==23.1.0
|
||||
pytest==8.3.3
|
||||
ruff==0.6.9
|
||||
mypy==1.11.2
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
92
backend/tests/conftest.py
Normal file
92
backend/tests/conftest.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Shared pytest fixtures."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.app import create_app
|
||||
from backend.app.auth import hash_password
|
||||
from backend.app.config import TestConfig
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User, UserRole
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app() -> Generator[Flask, None, None]:
|
||||
application = create_app(TestConfig())
|
||||
with application.app_context():
|
||||
db.create_all()
|
||||
yield application
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app: Flask) -> FlaskClient:
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_user(app: Flask) -> User:
|
||||
user = User(
|
||||
username="admin1",
|
||||
password_hash=hash_password("adminpass1"),
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def redteam_user(app: Flask) -> User:
|
||||
user = User(
|
||||
username="redteam1",
|
||||
password_hash=hash_password("redteampass1"),
|
||||
role=UserRole.REDTEAM,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def soc_user(app: Flask) -> User:
|
||||
user = User(
|
||||
username="soc1",
|
||||
password_hash=hash_password("socpass1"),
|
||||
role=UserRole.SOC,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _login(client: FlaskClient, username: str, password: str) -> str:
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": username, "password": password}
|
||||
)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_token(client: FlaskClient, admin_user: User) -> str:
|
||||
return _login(client, "admin1", "adminpass1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def redteam_token(client: FlaskClient, redteam_user: User) -> str:
|
||||
return _login(client, "redteam1", "redteampass1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def soc_token(client: FlaskClient, soc_user: User) -> str:
|
||||
return _login(client, "soc1", "socpass1")
|
||||
|
||||
|
||||
def auth_headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
41
backend/tests/test_app.py
Normal file
41
backend/tests/test_app.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""App-level tests: SPA fallback, health, error shapes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
|
||||
def test_health_endpoint(client: FlaskClient) -> None:
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_unknown_api_path_returns_json_404(client: FlaskClient) -> None:
|
||||
"""SPA fallback must not shadow unknown /api/* routes with index.html."""
|
||||
resp = client.get("/api/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert resp.is_json, f"expected JSON, got Content-Type={resp.content_type}"
|
||||
assert resp.get_json() == {"error": "Not found"}
|
||||
|
||||
|
||||
def test_unknown_nested_api_path_returns_json_404(client: FlaskClient) -> None:
|
||||
resp = client.get("/api/foo/bar/baz")
|
||||
assert resp.status_code == 404
|
||||
assert resp.is_json
|
||||
assert resp.get_json() == {"error": "Not found"}
|
||||
|
||||
|
||||
def test_wrong_method_on_api_returns_json(client: FlaskClient) -> None:
|
||||
# PUT is not defined on /api/auth/login — should stay JSON, not HTML.
|
||||
resp = client.put("/api/auth/login")
|
||||
assert resp.status_code in (404, 405)
|
||||
assert resp.is_json
|
||||
|
||||
|
||||
def test_index_without_built_frontend_returns_json(client: FlaskClient) -> None:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
# Tests run with no built frontend → factory falls back to a JSON status payload.
|
||||
assert resp.is_json
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
93
backend/tests/test_auth.py
Normal file
93
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Auth endpoint tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.app.models import User
|
||||
|
||||
|
||||
def test_login_success(client: FlaskClient, admin_user: User) -> None:
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "admin1", "password": "adminpass1"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert "access_token" in body and body["access_token"]
|
||||
assert body["user"] == {"id": admin_user.id, "username": "admin1", "role": "admin"}
|
||||
|
||||
|
||||
def test_login_wrong_password(client: FlaskClient, admin_user: User) -> None:
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "admin1", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json() == {"error": "Invalid credentials"}
|
||||
|
||||
|
||||
def test_login_unknown_user(client: FlaskClient) -> None:
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "ghost", "password": "anything!!"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
# Generic message — must not leak whether username exists.
|
||||
assert resp.get_json() == {"error": "Invalid credentials"}
|
||||
|
||||
|
||||
def test_login_missing_fields(client: FlaskClient) -> None:
|
||||
resp = client.post("/api/auth/login", json={})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_me_requires_token(client: FlaskClient) -> None:
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_me_returns_current_user(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["username"] == "admin1"
|
||||
assert body["role"] == "admin"
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
def test_me_with_invalid_token(client: FlaskClient) -> None:
|
||||
resp = client.get("/api/auth/me", headers={"Authorization": "Bearer not.a.jwt"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_me_with_expired_token(
|
||||
app: Flask, client: FlaskClient, admin_user: User
|
||||
) -> None:
|
||||
now = datetime.now(UTC) - timedelta(minutes=120)
|
||||
payload = {
|
||||
"sub": str(admin_user.id),
|
||||
"role": "admin",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=1)).timestamp()),
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload, app.config["JWT_SECRET"], algorithm=app.config["JWT_ALGORITHM"]
|
||||
)
|
||||
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json() == {"error": "Token expired"}
|
||||
|
||||
|
||||
def test_logout_ok_with_token(client: FlaskClient, admin_token: str) -> None:
|
||||
resp = client.post(
|
||||
"/api/auth/logout", headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_logout_without_token_is_401(client: FlaskClient) -> None:
|
||||
resp = client.post("/api/auth/logout")
|
||||
assert resp.status_code == 401
|
||||
47
backend/tests/test_cli_create_admin.py
Normal file
47
backend/tests/test_cli_create_admin.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""CLI tests for `flask create-admin`."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from backend.app.auth import hash_password
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User, UserRole
|
||||
|
||||
|
||||
def test_create_admin_success(app: Flask) -> None:
|
||||
runner = app.test_cli_runner()
|
||||
result = runner.invoke(args=["create-admin", "alice", "p4ssw0rd"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "created" in result.output.lower()
|
||||
user = User.query.filter_by(username="alice").first()
|
||||
assert user is not None
|
||||
assert user.role == UserRole.ADMIN
|
||||
|
||||
|
||||
def test_create_admin_duplicate_username(app: Flask) -> None:
|
||||
existing = User(
|
||||
username="bob", password_hash=hash_password("originalpw"), role=UserRole.ADMIN
|
||||
)
|
||||
db.session.add(existing)
|
||||
db.session.commit()
|
||||
|
||||
runner = app.test_cli_runner()
|
||||
result = runner.invoke(args=["create-admin", "bob", "anotherpw1"])
|
||||
assert result.exit_code != 0
|
||||
assert "exists" in (result.output + (result.stderr_bytes.decode() if result.stderr_bytes else "")).lower()
|
||||
|
||||
|
||||
def test_create_admin_short_password(app: Flask) -> None:
|
||||
runner = app.test_cli_runner()
|
||||
result = runner.invoke(args=["create-admin", "charlie", "abc"])
|
||||
assert result.exit_code != 0
|
||||
combined = (result.output + (result.stderr_bytes.decode() if result.stderr_bytes else "")).lower()
|
||||
assert "8 characters" in combined
|
||||
assert User.query.filter_by(username="charlie").first() is None
|
||||
|
||||
|
||||
def test_create_admin_missing_args(app: Flask) -> None:
|
||||
runner = app.test_cli_runner()
|
||||
result = runner.invoke(args=["create-admin"])
|
||||
# Click's UsageError exits with code 2
|
||||
assert result.exit_code != 0
|
||||
281
backend/tests/test_engagements.py
Normal file
281
backend/tests/test_engagements.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Engagement endpoint tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.app.models import User
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
|
||||
|
||||
def _create(
|
||||
client: FlaskClient, token: str, **overrides: object
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"name": "Op Alpha",
|
||||
"description": "first engagement",
|
||||
"start_date": "2026-06-01",
|
||||
"end_date": "2026-06-10",
|
||||
"status": "planned",
|
||||
}
|
||||
payload.update(overrides)
|
||||
resp = client.post("/api/engagements", headers=_h(token), json=payload)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def test_create_engagement_as_redteam(
|
||||
client: FlaskClient, redteam_user: User, redteam_token: str
|
||||
) -> None:
|
||||
body = _create(client, redteam_token)
|
||||
assert body["name"] == "Op Alpha"
|
||||
assert body["status"] == "planned"
|
||||
assert body["created_by"] == {"id": redteam_user.id, "username": "redteam1"}
|
||||
|
||||
|
||||
def test_create_engagement_as_admin(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
body = _create(client, admin_token, name="Op Admin")
|
||||
assert body["created_by"]["username"] == "admin1"
|
||||
|
||||
|
||||
def test_create_engagement_soc_forbidden(
|
||||
client: FlaskClient, soc_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(soc_token),
|
||||
json={"name": "x", "start_date": "2026-06-01"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_create_engagement_unauth(client: FlaskClient) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements", json={"name": "x", "start_date": "2026-06-01"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_create_engagement_missing_name(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(redteam_token),
|
||||
json={"start_date": "2026-06-01"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_engagement_bad_date(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(redteam_token),
|
||||
json={"name": "bad", "start_date": "not-a-date"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_engagement_end_before_start(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(redteam_token),
|
||||
json={
|
||||
"name": "bad",
|
||||
"start_date": "2026-06-10",
|
||||
"end_date": "2026-06-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_engagement_bad_status(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(redteam_token),
|
||||
json={"name": "bad", "start_date": "2026-06-01", "status": "wat"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_engagement_default_status_planned(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(redteam_token),
|
||||
json={"name": "Op default", "start_date": "2026-06-01"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()["status"] == "planned"
|
||||
|
||||
|
||||
def test_list_engagements_all_roles_can_read(
|
||||
client: FlaskClient,
|
||||
redteam_token: str,
|
||||
soc_token: str,
|
||||
admin_token: str,
|
||||
) -> None:
|
||||
_create(client, redteam_token)
|
||||
for token in (redteam_token, soc_token, admin_token):
|
||||
resp = client.get("/api/engagements", headers=_h(token))
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert isinstance(body, list)
|
||||
assert len(body) >= 1
|
||||
assert body[0]["created_by"] == {
|
||||
"id": body[0]["created_by"]["id"],
|
||||
"username": body[0]["created_by"]["username"],
|
||||
}
|
||||
|
||||
|
||||
def test_get_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = client.get("/api/engagements/9999", headers=_h(redteam_token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_engagement_ok(client: FlaskClient, redteam_token: str) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.get(
|
||||
f"/api/engagements/{created['id']}", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["id"] == created["id"]
|
||||
|
||||
|
||||
def test_patch_engagement_redteam(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(redteam_token),
|
||||
json={"status": "active", "description": "now in progress"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "active"
|
||||
assert body["description"] == "now in progress"
|
||||
|
||||
|
||||
def test_patch_engagement_admin(
|
||||
client: FlaskClient, redteam_token: str, admin_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(admin_token),
|
||||
json={"name": "Op renamed"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["name"] == "Op renamed"
|
||||
|
||||
|
||||
def test_patch_engagement_bad_status(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(redteam_token),
|
||||
json={"status": "wat"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_patch_engagement_soc_forbidden(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(soc_token),
|
||||
json={"status": "closed"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_patch_engagement_end_before_start(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token, start_date="2026-06-01")
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(redteam_token),
|
||||
json={"end_date": "2026-05-30"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_patch_engagement_clear_end_date(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token, end_date="2026-06-30")
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(redteam_token),
|
||||
json={"end_date": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["end_date"] is None
|
||||
|
||||
|
||||
def test_patch_engagement_empty_name_rejected(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.patch(
|
||||
f"/api/engagements/{created['id']}",
|
||||
headers=_h(redteam_token),
|
||||
json={"name": " "},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_patch_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = client.patch(
|
||||
"/api/engagements/9999", headers=_h(redteam_token), json={"name": "x"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_engagement_redteam(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.delete(
|
||||
f"/api/engagements/{created['id']}", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_delete_engagement_admin(
|
||||
client: FlaskClient, redteam_token: str, admin_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.delete(
|
||||
f"/api/engagements/{created['id']}", headers=_h(admin_token)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_delete_engagement_soc_forbidden(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
created = _create(client, redteam_token)
|
||||
resp = client.delete(
|
||||
f"/api/engagements/{created['id']}", headers=_h(soc_token)
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_delete_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = client.delete("/api/engagements/9999", headers=_h(redteam_token))
|
||||
assert resp.status_code == 404
|
||||
201
backend/tests/test_users.py
Normal file
201
backend/tests/test_users.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""User management endpoint tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.app.auth import hash_password
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User, UserRole
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
|
||||
|
||||
def test_list_users_admin_only(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
resp = client.get("/api/users", headers=_h(admin_token))
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert isinstance(body, list)
|
||||
assert any(u["username"] == "admin1" for u in body)
|
||||
assert all("password_hash" not in u for u in body)
|
||||
|
||||
|
||||
def test_list_users_forbidden_for_redteam(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.get("/api/users", headers=_h(redteam_token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_list_users_forbidden_for_soc(client: FlaskClient, soc_token: str) -> None:
|
||||
resp = client.get("/api/users", headers=_h(soc_token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_list_users_unauth(client: FlaskClient) -> None:
|
||||
resp = client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_create_user_success(client: FlaskClient, admin_token: str) -> None:
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
headers=_h(admin_token),
|
||||
json={"username": "newbie", "password": "longenough1", "role": "redteam"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.get_json()
|
||||
assert body["username"] == "newbie"
|
||||
assert body["role"] == "redteam"
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
def test_create_user_duplicate_username(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
headers=_h(admin_token),
|
||||
json={"username": "admin1", "password": "longenough1", "role": "redteam"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "exists" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_create_user_short_password(client: FlaskClient, admin_token: str) -> None:
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
headers=_h(admin_token),
|
||||
json={"username": "short", "password": "abc", "role": "soc"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "8 characters" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_create_user_invalid_role(client: FlaskClient, admin_token: str) -> None:
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
headers=_h(admin_token),
|
||||
json={"username": "x", "password": "longenough1", "role": "godmode"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_user_forbidden_for_non_admin(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
headers=_h(redteam_token),
|
||||
json={"username": "x", "password": "longenough1", "role": "soc"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_patch_user_change_role(
|
||||
client: FlaskClient, admin_token: str, soc_user: User
|
||||
) -> None:
|
||||
resp = client.patch(
|
||||
f"/api/users/{soc_user.id}",
|
||||
headers=_h(admin_token),
|
||||
json={"role": "redteam"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["role"] == "redteam"
|
||||
|
||||
|
||||
def test_patch_user_change_password(
|
||||
client: FlaskClient, admin_token: str, soc_user: User
|
||||
) -> None:
|
||||
resp = client.patch(
|
||||
f"/api/users/{soc_user.id}",
|
||||
headers=_h(admin_token),
|
||||
json={"password": "anotherone1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# New password should now allow login.
|
||||
login = client.post(
|
||||
"/api/auth/login", json={"username": "soc1", "password": "anotherone1"}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
|
||||
|
||||
def test_patch_user_short_password(
|
||||
client: FlaskClient, admin_token: str, soc_user: User
|
||||
) -> None:
|
||||
resp = client.patch(
|
||||
f"/api/users/{soc_user.id}",
|
||||
headers=_h(admin_token),
|
||||
json={"password": "no"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_patch_user_404(client: FlaskClient, admin_token: str) -> None:
|
||||
resp = client.patch(
|
||||
"/api/users/9999", headers=_h(admin_token), json={"role": "soc"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_patch_user_forbidden_for_redteam(
|
||||
client: FlaskClient, redteam_token: str, soc_user: User
|
||||
) -> None:
|
||||
resp = client.patch(
|
||||
f"/api/users/{soc_user.id}", headers=_h(redteam_token), json={"role": "admin"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_delete_user_success(
|
||||
client: FlaskClient, admin_token: str, soc_user: User
|
||||
) -> None:
|
||||
resp = client.delete(f"/api/users/{soc_user.id}", headers=_h(admin_token))
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_delete_last_admin_blocked(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
resp = client.delete(f"/api/users/{admin_user.id}", headers=_h(admin_token))
|
||||
assert resp.status_code == 409
|
||||
assert "last admin" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_delete_admin_when_other_admin_exists(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
other = User(
|
||||
username="admin2",
|
||||
password_hash=hash_password("adminpass2"),
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
db.session.add(other)
|
||||
db.session.commit()
|
||||
other_id = other.id
|
||||
|
||||
resp = client.delete(f"/api/users/{other_id}", headers=_h(admin_token))
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_demote_last_admin_blocked(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
resp = client.patch(
|
||||
f"/api/users/{admin_user.id}",
|
||||
headers=_h(admin_token),
|
||||
json={"role": "redteam"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_delete_user_404(client: FlaskClient, admin_token: str) -> None:
|
||||
resp = client.delete("/api/users/9999", headers=_h(admin_token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_user_forbidden_for_soc(
|
||||
client: FlaskClient, soc_token: str, redteam_user: User
|
||||
) -> None:
|
||||
resp = client.delete(f"/api/users/{redteam_user.id}", headers=_h(soc_token))
|
||||
assert resp.status_code == 403
|
||||
30
docker/Dockerfile
Normal file
30
docker/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Stage 1: build front
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: python runtime
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt ./backend/
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
COPY backend/ ./backend/
|
||||
COPY --from=frontend-build /app/frontend/dist ./backend/app/static
|
||||
|
||||
ENV FLASK_APP=backend.app:create_app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app
|
||||
# Variables surchargeables au `docker run` :
|
||||
ENV MIMIC_PORT=5000
|
||||
ENV MIMIC_DB_PATH=/data/mimic.sqlite
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 5000
|
||||
|
||||
# Entrypoint : applique les migrations Alembic puis lance Flask
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
4
docker/entrypoint.sh
Executable file
4
docker/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
flask db upgrade
|
||||
exec flask run --host=0.0.0.0 --port="${MIMIC_PORT:-5000}"
|
||||
171
e2e/fixtures/api.ts
Normal file
171
e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Thin axios client used by tests to seed/teardown users and engagements
|
||||
* without going through the UI. The bootstrap admin is created out-of-band
|
||||
* (via `make create-admin`) and logs in once to provision per-suite users.
|
||||
*/
|
||||
import axios, { AxiosInstance, isAxiosError } from 'axios';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'redteam' | 'soc';
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
status: 'planned' | 'active' | 'closed';
|
||||
created_at: string | null;
|
||||
created_by: { id: number; username: string } | null;
|
||||
}
|
||||
|
||||
export type Role = User['role'];
|
||||
|
||||
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||
const BOOTSTRAP_ADMIN_USER = process.env.MIMIC_BOOTSTRAP_USER ?? 'root';
|
||||
const BOOTSTRAP_ADMIN_PASS = process.env.MIMIC_BOOTSTRAP_PASS ?? 'rootpass8';
|
||||
|
||||
export function makeClient(token?: string): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: `${BASE_URL}/api`,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
validateStatus: () => true, // tests assert on status themselves
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const client = makeClient();
|
||||
const r = await client.post('/auth/login', { username, password });
|
||||
if (r.status !== 200) {
|
||||
throw new Error(`login(${username}) failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return { token: r.data.access_token as string, user: r.data.user as User };
|
||||
}
|
||||
|
||||
export async function adminToken(): Promise<string> {
|
||||
const { token } = await login(BOOTSTRAP_ADMIN_USER, BOOTSTRAP_ADMIN_PASS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent helper: ensures a user with the given username/role exists and
|
||||
* has the requested password. Returns the user record.
|
||||
*
|
||||
* Strategy:
|
||||
* - try login: if it succeeds, we're done.
|
||||
* - else: as admin, list users; if username found, PATCH password+role; else POST.
|
||||
*/
|
||||
export async function ensureUser(
|
||||
username: string,
|
||||
password: string,
|
||||
role: Role,
|
||||
): Promise<User> {
|
||||
try {
|
||||
const { user } = await login(username, password);
|
||||
if (user.role !== role) {
|
||||
const admin = await adminToken();
|
||||
const client = makeClient(admin);
|
||||
const r = await client.patch(`/users/${user.id}`, { role });
|
||||
if (r.status !== 200) throw new Error(`patch role: ${r.status}`);
|
||||
return r.data as User;
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
// fall through to admin path
|
||||
}
|
||||
const admin = await adminToken();
|
||||
const client = makeClient(admin);
|
||||
const list = await client.get('/users');
|
||||
if (list.status !== 200) {
|
||||
throw new Error(`list users failed: ${list.status} ${JSON.stringify(list.data)}`);
|
||||
}
|
||||
const existing = (list.data as User[]).find((u) => u.username === username);
|
||||
if (existing) {
|
||||
const r = await client.patch(`/users/${existing.id}`, { password, role });
|
||||
if (r.status !== 200) {
|
||||
throw new Error(`patch user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as User;
|
||||
}
|
||||
const r = await client.post('/users', { username, password, role });
|
||||
if (r.status !== 201) {
|
||||
throw new Error(`create user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as User;
|
||||
}
|
||||
|
||||
export async function deleteUserByUsername(token: string, username: string): Promise<void> {
|
||||
const client = makeClient(token);
|
||||
const list = await client.get('/users');
|
||||
if (list.status !== 200) return;
|
||||
const u = (list.data as User[]).find((x) => x.username === username);
|
||||
if (!u) return;
|
||||
await client.delete(`/users/${u.id}`);
|
||||
}
|
||||
|
||||
export async function createEngagement(
|
||||
token: string,
|
||||
payload: Partial<Pick<Engagement, 'name' | 'description' | 'start_date' | 'end_date' | 'status'>>,
|
||||
): Promise<Engagement> {
|
||||
const client = makeClient(token);
|
||||
const body = {
|
||||
name: payload.name ?? 'Test Engagement',
|
||||
description: payload.description,
|
||||
start_date: payload.start_date ?? '2026-01-01',
|
||||
end_date: payload.end_date,
|
||||
status: payload.status ?? 'planned',
|
||||
};
|
||||
const r = await client.post('/engagements', body);
|
||||
if (r.status !== 201) {
|
||||
throw new Error(`create engagement failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as Engagement;
|
||||
}
|
||||
|
||||
export async function deleteEngagement(token: string, id: number): Promise<void> {
|
||||
const client = makeClient(token);
|
||||
await client.delete(`/engagements/${id}`);
|
||||
}
|
||||
|
||||
export async function listEngagements(token: string): Promise<Engagement[]> {
|
||||
const client = makeClient(token);
|
||||
const r = await client.get('/engagements');
|
||||
if (r.status !== 200) {
|
||||
throw new Error(`list engagements failed: ${r.status}`);
|
||||
}
|
||||
return r.data as Engagement[];
|
||||
}
|
||||
|
||||
export async function deleteAllEngagements(token: string): Promise<void> {
|
||||
const items = await listEngagements(token);
|
||||
await Promise.all(items.map((e) => deleteEngagement(token, e.id)));
|
||||
}
|
||||
|
||||
export async function waitForHealth(timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const client = makeClient();
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await client.get('/health');
|
||||
if (r.status === 200) return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`backend not healthy after ${timeoutMs}ms: ${
|
||||
isAxiosError(lastErr) ? lastErr.message : String(lastErr)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const BASE = BASE_URL;
|
||||
67
e2e/fixtures/auth.ts
Normal file
67
e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Playwright auth helpers: log in via the API once per role, hydrate
|
||||
* localStorage so subsequent page.goto() lands inside the SPA already
|
||||
* authenticated.
|
||||
*
|
||||
* Avoids the per-test cost of driving the LoginPage form just to land on
|
||||
* /engagements. The actual UI login flow IS exercised in us2-login.spec.ts.
|
||||
*/
|
||||
import { type BrowserContext, type Page } from '@playwright/test';
|
||||
import { BASE, login, type Role, type User } from './api';
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject token into localStorage so the SPA's bootstrap hook picks it up
|
||||
* as if the user had already logged in. The frontend stores the JWT under
|
||||
* `mimic.token` (see frontend/src/api/client.ts).
|
||||
*/
|
||||
export async function seedTokenInStorage(
|
||||
context: BrowserContext,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await context.addInitScript((t) => {
|
||||
try {
|
||||
window.localStorage.setItem('mimic.token', t);
|
||||
} catch {
|
||||
/* storage might not exist on about:blank — harmless */
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function clearAuthStorage(context: BrowserContext): Promise<void> {
|
||||
await context.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('mimic.token');
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the given role and return both the API session and a helper
|
||||
* that prepares a Page with the auth token already seeded.
|
||||
*/
|
||||
export async function loginAs(
|
||||
context: BrowserContext,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Session> {
|
||||
const session = await login(username, password);
|
||||
await seedTokenInStorage(context, session.token);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: navigate to a path on a page that's already had its
|
||||
* context seeded with a token.
|
||||
*/
|
||||
export async function gotoApp(page: Page, path = '/engagements'): Promise<void> {
|
||||
await page.goto(`${BASE}${path}`);
|
||||
}
|
||||
|
||||
export type { Role };
|
||||
470
e2e/package-lock.json
generated
Normal file
470
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"name": "mimic-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mimic-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^22.7.5",
|
||||
"axios": "^1.7.7",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
e2e/package.json
Normal file
17
e2e/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "mimic-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^22.7.5",
|
||||
"axios": "^1.7.7",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
40
e2e/playwright.config.ts
Normal file
40
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Single-container e2e setup — Mimic backend serves both /api/* and the built SPA.
|
||||
*
|
||||
* Sequence (run manually before `npx playwright test`):
|
||||
* 1. make build && make start
|
||||
* 2. make create-admin USER=root PASS=rootpass8
|
||||
* 3. ensure `curl /api/health` is 200
|
||||
*
|
||||
* Tests run **serially** because all state lives in a single SQLite file in the
|
||||
* shared container. RBAC tests need stable user fixtures across spec files.
|
||||
*/
|
||||
const baseURL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
use: {
|
||||
baseURL,
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
extraHTTPHeaders: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
109
e2e/tests/us1-bootstrap-admin.spec.ts
Normal file
109
e2e/tests/us1-bootstrap-admin.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* US-1 — bootstrap the first admin via `flask create-admin`.
|
||||
*
|
||||
* The `make create-admin` target wraps `docker exec mimic flask create-admin …`.
|
||||
* These tests exercise the CLI directly through `docker exec` (or whatever
|
||||
* runtime the harness exposes via the wrapper), and a follow-up API login to
|
||||
* confirm the row was created with role=admin and an argon2 hash that verifies.
|
||||
*
|
||||
* NOTE: the bootstrap admin (`root` / `rootpass8`) is already created out-of-band
|
||||
* before the Playwright suite starts. Test usernames here are scoped to `us1-*`
|
||||
* and cleaned up via API in afterAll.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { adminToken, deleteUserByUsername, login, makeClient } from '../fixtures/api';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
|
||||
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
|
||||
|
||||
function runCreateAdmin(user: string, pass: string): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
} {
|
||||
try {
|
||||
const stdout = execSync(`${RUNTIME} exec ${CONTAINER} flask create-admin ${user} ${pass}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return { stdout, stderr: '', status: 0 };
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
return {
|
||||
stdout: typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '',
|
||||
stderr: typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '',
|
||||
status: err.status ?? -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US-1 — bootstrap first admin', () => {
|
||||
const probeUser = 'us1-probe-admin';
|
||||
const dupUser = 'us1-dup-admin';
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const token = await adminToken();
|
||||
for (const u of [probeUser, dupUser]) {
|
||||
await deleteUserByUsername(token, u);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-1.1 — create-admin creates a user with role=admin and an argon2 hash that authenticates', async () => {
|
||||
const probePass = 'probepass123';
|
||||
const result = runCreateAdmin(probeUser, probePass);
|
||||
expect(result.status, `CLI failed: ${result.stderr || result.stdout}`).toBe(0);
|
||||
expect(result.stdout).toMatch(/created/i);
|
||||
|
||||
// Roundtrip: the resulting credentials must log in as role=admin.
|
||||
const { user } = await login(probeUser, probePass);
|
||||
expect(user.username).toBe(probeUser);
|
||||
expect(user.role).toBe('admin');
|
||||
});
|
||||
|
||||
test('AC-1.2 — fails cleanly when username already exists', async () => {
|
||||
// Seed once, then call again.
|
||||
runCreateAdmin(dupUser, 'firstpass8');
|
||||
const second = runCreateAdmin(dupUser, 'secondpass8');
|
||||
expect(second.status).not.toBe(0);
|
||||
const combined = (second.stderr + second.stdout).toLowerCase();
|
||||
expect(combined).toMatch(/exists|already|error/);
|
||||
});
|
||||
|
||||
test('AC-1.3 — refuses passwords shorter than 8 characters', async () => {
|
||||
const result = runCreateAdmin('us1-shortpass-user', 'short7');
|
||||
expect(result.status).not.toBe(0);
|
||||
const combined = (result.stderr + result.stdout).toLowerCase();
|
||||
expect(combined).toMatch(/8|length|password/);
|
||||
|
||||
// Make sure the short-password attempt did NOT create a row.
|
||||
const probe = await makeClient().post('/auth/login', {
|
||||
username: 'us1-shortpass-user',
|
||||
password: 'short7',
|
||||
});
|
||||
expect(probe.status).toBe(401);
|
||||
});
|
||||
|
||||
test('AC-1.4 — make create-admin wraps `docker exec mimic flask create-admin` (the bootstrap admin proves it ran via that path)', async () => {
|
||||
// The harness step `make create-admin USER=root PASS=rootpass8` is what
|
||||
// got the suite to the point where adminToken() works. If that call had
|
||||
// been broken, this assertion would have already failed when seeding.
|
||||
const token = await adminToken();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
// Defence-in-depth: assert the Makefile target literally invokes
|
||||
// `docker exec … flask create-admin`. This is a contract check.
|
||||
const makefilePath = resolve(__dirname, '../..', 'Makefile');
|
||||
const content = readFileSync(makefilePath, 'utf8');
|
||||
expect(content).toMatch(/docker exec .+ flask create-admin/);
|
||||
});
|
||||
});
|
||||
158
e2e/tests/us2-login.spec.ts
Normal file
158
e2e/tests/us2-login.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* US-2 — login / logout / session expiry.
|
||||
*
|
||||
* Covers AC-2.1 through AC-2.6. Mix of pure-API assertions (token shape +
|
||||
* generic 401) and full UI flow (form submit, redirect, expired-token toast).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { adminToken, ensureUser, deleteUserByUsername, makeClient } from '../fixtures/api';
|
||||
import { clearAuthStorage, seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const USERNAME = 'us2-loginuser';
|
||||
const PASSWORD = 'us2-pass8chars';
|
||||
|
||||
test.describe('US-2 — auth flow', () => {
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(USERNAME, PASSWORD, 'redteam');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const token = await adminToken();
|
||||
await deleteUserByUsername(token, USERNAME);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-2.1 — POST /api/auth/login returns access_token + user payload', async () => {
|
||||
const client = makeClient();
|
||||
const r = await client.post('/auth/login', {
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(typeof r.data.access_token).toBe('string');
|
||||
expect(r.data.access_token.length).toBeGreaterThan(20);
|
||||
expect(r.data.user).toMatchObject({
|
||||
username: USERNAME,
|
||||
role: 'redteam',
|
||||
});
|
||||
expect(typeof r.data.user.id).toBe('number');
|
||||
});
|
||||
|
||||
test('AC-2.2 — bad credentials return 401 with generic error (no username/password leak)', async () => {
|
||||
const client = makeClient();
|
||||
|
||||
const wrongPass = await client.post('/auth/login', {
|
||||
username: USERNAME,
|
||||
password: 'nope-not-the-password',
|
||||
});
|
||||
expect(wrongPass.status).toBe(401);
|
||||
expect(wrongPass.data.error).toBe('Invalid credentials');
|
||||
|
||||
const noUser = await client.post('/auth/login', {
|
||||
username: 'does-not-exist-anywhere',
|
||||
password: 'whatever12345',
|
||||
});
|
||||
expect(noUser.status).toBe(401);
|
||||
expect(noUser.data.error).toBe('Invalid credentials');
|
||||
|
||||
// Critical: same message in both cases — no enumeration of users.
|
||||
expect(noUser.data.error).toBe(wrongPass.data.error);
|
||||
});
|
||||
|
||||
test('AC-2.3 — logout: client-side token purge (UI clears localStorage)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="username"]', USERNAME);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/engagements\b/);
|
||||
|
||||
// Token must now exist in localStorage.
|
||||
const before = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
|
||||
expect(before).toBeTruthy();
|
||||
|
||||
// Click "Sign out" (Layout topbar).
|
||||
await page.getByRole('button', { name: /sign out/i }).click();
|
||||
await page.waitForURL(/\/login\b/);
|
||||
|
||||
const after = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
test('AC-2.4 — /login form: success → /engagements, failure → visible error message', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
|
||||
|
||||
// Failure path first.
|
||||
await page.fill('input[name="username"]', USERNAME);
|
||||
await page.fill('input[name="password"]', 'wrongpassword');
|
||||
await page.click('button[type="submit"]');
|
||||
const errorLocator = page.getByTestId('login-error');
|
||||
await expect(errorLocator).toBeVisible();
|
||||
await expect(errorLocator).toHaveText(/invalid credentials/i);
|
||||
|
||||
// Now success.
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/engagements\b/);
|
||||
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-2.5 — navigating to /engagements without a token redirects to /login', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await clearAuthStorage(context);
|
||||
await context.clearCookies();
|
||||
await page.goto('/engagements');
|
||||
await page.waitForURL(/\/login\b/);
|
||||
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-2.6 — 401 from API purges token + redirects to /login with "Session expirée" toast', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Seed a clearly-invalid token so the very first API call (from /me on
|
||||
// app bootstrap) returns 401, tripping the axios interceptor.
|
||||
await seedTokenInStorage(context, 'totally.invalid.jwt');
|
||||
|
||||
// Race-safe toast capture: poll the DOM while React renders + the
|
||||
// interceptor's window.location.assign('/login') tears the page down.
|
||||
let sawToast = false;
|
||||
const stopWatching = page
|
||||
.waitForSelector('[data-testid="toast"]:has-text("Session expir")', {
|
||||
state: 'visible',
|
||||
timeout: 8_000,
|
||||
})
|
||||
.then(() => {
|
||||
sawToast = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* didn't catch the toast in time — assertion below will flag */
|
||||
});
|
||||
|
||||
await page.goto('/engagements');
|
||||
|
||||
// Interceptor should boot us to /login.
|
||||
await page.waitForURL(/\/login\b/, { timeout: 10_000 });
|
||||
|
||||
// Storage must be purged. Use waitForFunction to dodge the navigation race.
|
||||
await page.waitForFunction(() => window.localStorage.getItem('mimic.token') === null, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await stopWatching;
|
||||
expect(
|
||||
sawToast,
|
||||
'expected "Session expirée" toast to be visible at some point during the 401 redirect',
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
235
e2e/tests/us3-users-admin.spec.ts
Normal file
235
e2e/tests/us3-users-admin.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* US-3 — admin manages user accounts.
|
||||
* Covers AC-3.1 → AC-3.7. RBAC matrix exercised at the API for each verb,
|
||||
* and the /admin/users page is exercised in the UI for create + role-gate.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
makeClient,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us3-redteam';
|
||||
const SOC_USER = 'us3-soc';
|
||||
const PASS = 'us3-pass-strong';
|
||||
|
||||
test.describe('US-3 — user admin', () => {
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
await ensureUser(SOC_USER, PASS, 'soc');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const token = await adminToken();
|
||||
for (const u of [
|
||||
REDTEAM_USER,
|
||||
SOC_USER,
|
||||
'us3-created-via-api',
|
||||
'us3-patched',
|
||||
'us3-deleted',
|
||||
'us3-ui-newuser',
|
||||
]) {
|
||||
await deleteUserByUsername(token, u);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-3.1 — GET /api/users returns list for admin', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
const r = await client.get('/users');
|
||||
expect(r.status).toBe(200);
|
||||
expect(Array.isArray(r.data)).toBe(true);
|
||||
const sample = (r.data as Array<Record<string, unknown>>).find(
|
||||
(u) => u.username === REDTEAM_USER,
|
||||
);
|
||||
expect(sample).toBeTruthy();
|
||||
expect(sample).toMatchObject({ role: 'redteam' });
|
||||
expect(sample!.password_hash).toBeUndefined();
|
||||
expect(sample!.id).toBeDefined();
|
||||
expect(sample!.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
test('AC-3.2 — POST /api/users creates user (201); 400 on duplicate or short password', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
|
||||
const created = await client.post('/users', {
|
||||
username: 'us3-created-via-api',
|
||||
password: 'longenough8',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.data).toMatchObject({
|
||||
username: 'us3-created-via-api',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(created.data.password_hash).toBeUndefined();
|
||||
|
||||
const dup = await client.post('/users', {
|
||||
username: 'us3-created-via-api',
|
||||
password: 'longenough8',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(dup.status).toBe(400);
|
||||
|
||||
const short = await client.post('/users', {
|
||||
username: 'us3-shortpw-x',
|
||||
password: 'short7',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(short.status).toBe(400);
|
||||
});
|
||||
|
||||
test('AC-3.3 — PATCH /api/users/<id> updates role and/or password', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
|
||||
// Seed a fresh user.
|
||||
const created = await client.post('/users', {
|
||||
username: 'us3-patched',
|
||||
password: 'initialpass8',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.data.id as number;
|
||||
|
||||
// PATCH role.
|
||||
const r1 = await client.patch(`/users/${id}`, { role: 'redteam' });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.data.role).toBe('redteam');
|
||||
|
||||
// PATCH password — must be usable for login.
|
||||
const r2 = await client.patch(`/users/${id}`, { password: 'newstrongpass8' });
|
||||
expect(r2.status).toBe(200);
|
||||
const { token: rotated } = await login('us3-patched', 'newstrongpass8');
|
||||
expect(rotated).toBeTruthy();
|
||||
});
|
||||
|
||||
test('AC-3.4 — DELETE /api/users/<id> returns 204; refuses last admin (409)', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
|
||||
// Create a disposable redteam, delete it → 204.
|
||||
const created = await client.post('/users', {
|
||||
username: 'us3-deleted',
|
||||
password: 'disposable8',
|
||||
role: 'redteam',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.data.id as number;
|
||||
|
||||
const del = await client.delete(`/users/${id}`);
|
||||
expect(del.status).toBe(204);
|
||||
|
||||
// Verify gone.
|
||||
const list = await client.get('/users');
|
||||
const found = (list.data as Array<{ id: number }>).find((u) => u.id === id);
|
||||
expect(found).toBeUndefined();
|
||||
|
||||
// Last-admin protection — list admins and try to delete the only one.
|
||||
const all = await client.get('/users');
|
||||
const admins = (all.data as Array<{ id: number; role: string }>).filter(
|
||||
(u) => u.role === 'admin',
|
||||
);
|
||||
if (admins.length === 1) {
|
||||
const r = await client.delete(`/users/${admins[0].id}`);
|
||||
expect(r.status).toBe(409);
|
||||
} else {
|
||||
// If suite added extra admins, demote-then-delete protection still applies:
|
||||
// we attempt a hypothetical demote of one admin (PATCH to redteam) and the
|
||||
// last one must be refused.
|
||||
// Iterate: keep deleting admins one by one until 1 remains, then assert 409.
|
||||
// (Skipped in well-isolated runs because typical state = 1 admin.)
|
||||
const ids = admins.map((a) => a.id);
|
||||
while (ids.length > 1) {
|
||||
const victim = ids.pop()!;
|
||||
const r = await client.delete(`/users/${victim}`);
|
||||
expect(r.status).toBe(204);
|
||||
}
|
||||
const finalId = ids[0];
|
||||
const r = await client.delete(`/users/${finalId}`);
|
||||
expect(r.status).toBe(409);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-3.5 — redteam and soc receive 403 on user-admin endpoints', async () => {
|
||||
for (const role of ['redteam', 'soc'] as const) {
|
||||
const username = role === 'redteam' ? REDTEAM_USER : SOC_USER;
|
||||
const { token } = await login(username, PASS);
|
||||
const client = makeClient(token);
|
||||
|
||||
const list = await client.get('/users');
|
||||
expect(list.status, `${role} GET /users`).toBe(403);
|
||||
|
||||
const post = await client.post('/users', {
|
||||
username: `${role}-attempt`,
|
||||
password: 'whatever8x',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(post.status, `${role} POST /users`).toBe(403);
|
||||
|
||||
const patch = await client.patch('/users/1', { role: 'soc' });
|
||||
expect(patch.status, `${role} PATCH /users/1`).toBe(403);
|
||||
|
||||
const del = await client.delete('/users/999');
|
||||
expect(del.status, `${role} DELETE /users/999`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-3.6 — /admin/users page lists users + allows create + reset-password + delete', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const token = await adminToken();
|
||||
await seedTokenInStorage(context, token);
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await expect(page.getByRole('heading', { name: /user accounts/i })).toBeVisible();
|
||||
|
||||
// Create new user via the form.
|
||||
const newName = 'us3-ui-newuser';
|
||||
await page.fill('#new-username', newName);
|
||||
await page.fill('#new-password', 'uistrongpw8');
|
||||
await page.selectOption('#new-role', 'soc');
|
||||
await page.getByRole('button', { name: /^create$/i }).click();
|
||||
|
||||
// Row appears.
|
||||
const row = page.getByRole('row', { name: new RegExp(newName) });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Reset password flow opens a sub-form.
|
||||
await row.getByRole('button', { name: /reset password/i }).click();
|
||||
await page.fill(`input[id^="reset-"]`, 'rotatedpass8');
|
||||
await page.getByRole('button', { name: /save password/i }).click();
|
||||
await expect(page.getByTestId('toast').filter({ hasText: /password reset/i })).toBeVisible();
|
||||
|
||||
// Delete row (confirm dialog).
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await row.getByRole('button', { name: /^delete$/i }).click();
|
||||
await expect(page.getByRole('row', { name: new RegExp(newName) })).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('AC-3.7 — redteam/soc visiting /admin/users → redirected to /engagements + "Accès refusé" toast', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const { token } = await login(SOC_USER, PASS);
|
||||
await seedTokenInStorage(context, token);
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.waitForURL(/\/engagements\b/, { timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId('toast').filter({ hasText: /accès refusé/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
270
e2e/tests/us4-engagements.spec.ts
Normal file
270
e2e/tests/us4-engagements.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* US-4 — engagement CRUD + RBAC + UI surfaces.
|
||||
* Covers AC-4.1 → AC-4.9.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteAllEngagements,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
listEngagements,
|
||||
login,
|
||||
makeClient,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us4-redteam';
|
||||
const SOC_USER = 'us4-soc';
|
||||
const PASS = 'us4-pass-strong';
|
||||
|
||||
test.describe('US-4 — engagement CRUD', () => {
|
||||
let redteamToken: string;
|
||||
let socToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
await ensureUser(SOC_USER, PASS, 'soc');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
socToken = (await login(SOC_USER, PASS)).token;
|
||||
// Clean slate so AC-4.7 list assertions are predictable.
|
||||
await deleteAllEngagements(await adminToken());
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteAllEngagements(tok);
|
||||
for (const u of [REDTEAM_USER, SOC_USER]) {
|
||||
await deleteUserByUsername(tok, u);
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-4.1 — GET /api/engagements returns serialized list (created_by = {id, username})', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.1 sample',
|
||||
start_date: '2026-02-01',
|
||||
});
|
||||
const items = await listEngagements(redteamToken);
|
||||
const row = items.find((i) => i.id === seeded.id);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row).toMatchObject({
|
||||
name: 'AC-4.1 sample',
|
||||
status: 'planned',
|
||||
start_date: '2026-02-01',
|
||||
});
|
||||
expect(row!.created_by).toMatchObject({ username: REDTEAM_USER });
|
||||
expect(typeof row!.created_by!.id).toBe('number');
|
||||
});
|
||||
|
||||
test('AC-4.2 — POST validates name/dates/status', async () => {
|
||||
const client = makeClient(redteamToken);
|
||||
|
||||
const blankName = await client.post('/engagements', {
|
||||
name: '',
|
||||
start_date: '2026-03-01',
|
||||
});
|
||||
expect(blankName.status).toBe(400);
|
||||
|
||||
const noStart = await client.post('/engagements', { name: 'x' });
|
||||
expect(noStart.status).toBe(400);
|
||||
|
||||
const badDate = await client.post('/engagements', {
|
||||
name: 'x',
|
||||
start_date: 'not-a-date',
|
||||
});
|
||||
expect(badDate.status).toBe(400);
|
||||
|
||||
const endBeforeStart = await client.post('/engagements', {
|
||||
name: 'x',
|
||||
start_date: '2026-04-10',
|
||||
end_date: '2026-04-01',
|
||||
});
|
||||
expect(endBeforeStart.status).toBe(400);
|
||||
|
||||
const badStatus = await client.post('/engagements', {
|
||||
name: 'x',
|
||||
start_date: '2026-04-01',
|
||||
status: 'frozen',
|
||||
});
|
||||
expect(badStatus.status).toBe(400);
|
||||
|
||||
const defaultStatus = await client.post('/engagements', {
|
||||
name: 'AC-4.2 default-status',
|
||||
start_date: '2026-04-01',
|
||||
});
|
||||
expect(defaultStatus.status).toBe(201);
|
||||
expect(defaultStatus.data.status).toBe('planned');
|
||||
});
|
||||
|
||||
test('AC-4.3 — GET /api/engagements/<id> returns 200 + object, 404 if unknown', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.3 sample',
|
||||
start_date: '2026-05-01',
|
||||
});
|
||||
const client = makeClient(redteamToken);
|
||||
const ok = await client.get(`/engagements/${seeded.id}`);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.data.id).toBe(seeded.id);
|
||||
|
||||
const missing = await client.get('/engagements/999999');
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
|
||||
test('AC-4.4 — PATCH (admin/redteam) updates fields', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.4 orig',
|
||||
start_date: '2026-06-01',
|
||||
});
|
||||
const client = makeClient(redteamToken);
|
||||
const r = await client.patch(`/engagements/${seeded.id}`, {
|
||||
name: 'AC-4.4 updated',
|
||||
status: 'active',
|
||||
end_date: '2026-06-15',
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data).toMatchObject({
|
||||
name: 'AC-4.4 updated',
|
||||
status: 'active',
|
||||
end_date: '2026-06-15',
|
||||
});
|
||||
});
|
||||
|
||||
test('AC-4.5 — DELETE (admin/redteam) returns 204', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.5 disposable',
|
||||
start_date: '2026-07-01',
|
||||
});
|
||||
const client = makeClient(redteamToken);
|
||||
const r = await client.delete(`/engagements/${seeded.id}`);
|
||||
expect(r.status).toBe(204);
|
||||
|
||||
const after = await client.get(`/engagements/${seeded.id}`);
|
||||
expect(after.status).toBe(404);
|
||||
});
|
||||
|
||||
test('AC-4.6 — soc can read but not write (403 on POST/PATCH/DELETE)', async () => {
|
||||
const socClient = makeClient(socToken);
|
||||
const list = await socClient.get('/engagements');
|
||||
expect(list.status).toBe(200);
|
||||
|
||||
const post = await socClient.post('/engagements', {
|
||||
name: 'soc-blocked',
|
||||
start_date: '2026-08-01',
|
||||
});
|
||||
expect(post.status).toBe(403);
|
||||
|
||||
// Seed via redteam to get a target id.
|
||||
const target = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.6 target',
|
||||
start_date: '2026-08-15',
|
||||
});
|
||||
|
||||
const patch = await socClient.patch(`/engagements/${target.id}`, { name: 'soc-edit' });
|
||||
expect(patch.status).toBe(403);
|
||||
|
||||
const del = await socClient.delete(`/engagements/${target.id}`);
|
||||
expect(del.status).toBe(403);
|
||||
|
||||
// Clean up via redteam.
|
||||
await deleteEngagement(redteamToken, target.id);
|
||||
});
|
||||
|
||||
test('AC-4.7 — /engagements page lists rows with required columns + role-aware buttons', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Seed one row visible to the redteam user.
|
||||
await createEngagement(redteamToken, {
|
||||
name: 'UI list sample',
|
||||
start_date: '2026-09-01',
|
||||
status: 'active',
|
||||
});
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
|
||||
// Column headers
|
||||
for (const h of ['Name', 'Status', 'Start', 'End', 'Created by']) {
|
||||
await expect(page.getByRole('columnheader', { name: new RegExp(h, 'i') })).toBeVisible();
|
||||
}
|
||||
// The row + status badge + created_by visible
|
||||
const row = page.getByRole('row', { name: /UI list sample/i });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
|
||||
|
||||
// Redteam sees the action buttons.
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible();
|
||||
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
|
||||
|
||||
// Soc should NOT see write buttons.
|
||||
await seedTokenInStorage(context, socToken);
|
||||
await page.goto('/engagements');
|
||||
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
|
||||
await expect(rowAsSoc).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AC-4.8 — /engagements/new form: client validation + API error display', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
|
||||
await page.goto('/engagements/new');
|
||||
await expect(page.getByRole('heading', { name: /new engagement/i })).toBeVisible();
|
||||
|
||||
// Submit empty → client-side errors visible.
|
||||
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||
await expect(page.getByText(/name is required/i)).toBeVisible();
|
||||
await expect(page.getByText(/start date is required/i)).toBeVisible();
|
||||
|
||||
// Fill bad date order → client validation flags end_date.
|
||||
await page.fill('#eng-name', 'UI form test');
|
||||
await page.fill('#eng-start', '2026-10-10');
|
||||
await page.fill('#eng-end', '2026-10-01');
|
||||
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||
await expect(page.getByText(/end date must be on or after start date/i)).toBeVisible();
|
||||
|
||||
// Fix dates → submit succeeds, redirects to detail.
|
||||
await page.fill('#eng-end', '2026-10-20');
|
||||
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||
await page.waitForURL(/\/engagements\/\d+$/);
|
||||
await expect(page.getByRole('heading', { name: /UI form test/i })).toBeVisible();
|
||||
|
||||
// Edit path: navigate to /edit and tweak.
|
||||
const detailUrl = page.url();
|
||||
const id = Number(detailUrl.split('/').pop());
|
||||
await page.goto(`/engagements/${id}/edit`);
|
||||
await expect(page.getByRole('heading', { name: /edit engagement/i })).toBeVisible();
|
||||
await page.fill('#eng-name', 'UI form test (edited)');
|
||||
await page.getByRole('button', { name: /save changes/i }).click();
|
||||
await page.waitForURL(new RegExp(`/engagements/${id}$`));
|
||||
await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-4.9 — /engagements/<id> detail page shows Sprint 2 placeholder', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.9 detail target',
|
||||
start_date: '2026-11-01',
|
||||
description: 'A description for detail rendering.',
|
||||
});
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${seeded.id}`);
|
||||
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/simulations à venir au sprint 2/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
161
e2e/tests/us5-design.spec.ts
Normal file
161
e2e/tests/us5-design.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* US-5 — UI respects DESIGN.md tokens, layout doesn't break at 1280×720,
|
||||
* and loading/error/empty states are wired.
|
||||
*
|
||||
* Pixel-perfect fidelity is NOT enforced — instead we assert that the
|
||||
* tokenised classes from tailwind.config.ts are present and that the canvas
|
||||
* has no horizontal overflow at the design viewport.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
deleteAllEngagements,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us5-redteam';
|
||||
const PASS = 'us5-passw0rd';
|
||||
|
||||
test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
|
||||
let redteamToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteAllEngagements(tok);
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-5.1 — DESIGN.md tokens applied (Inter font, brand palette, named utilities)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
// Body font must resolve to a stack including "Inter".
|
||||
const fontFamily = await page.evaluate(
|
||||
() => window.getComputedStyle(document.body).fontFamily,
|
||||
);
|
||||
expect(fontFamily.toLowerCase()).toMatch(/inter/);
|
||||
|
||||
// Tailwind-compiled DESIGN palette: the primary chevron at the top-left of
|
||||
// the nav uses `bg-primary` → rendered as rgb(2, 74, 216).
|
||||
const chevronBg = await page
|
||||
.locator('header a[aria-label="Mimic home"] span[aria-hidden]')
|
||||
.first()
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)');
|
||||
|
||||
// Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)).
|
||||
const utilityBg = await page
|
||||
.locator('div.bg-ink.text-ink-on')
|
||||
.first()
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)');
|
||||
|
||||
// Spot-check a few semantic class names live in the DOM (proves tokens are
|
||||
// wired through tailwind.config.ts and not ad-hoc hex values).
|
||||
await expect(page.locator('.btn-primary, .card-product, .max-w-page').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-5.2 — no horizontal overflow at 1280×720 across key pages', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
const routes = ['/engagements', '/engagements/new'];
|
||||
for (const route of routes) {
|
||||
await page.goto(route);
|
||||
const overflow = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(
|
||||
overflow.scrollWidth,
|
||||
`${route} should not horizontally overflow at 1280px`,
|
||||
).toBeLessThanOrEqual(overflow.clientWidth + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-5.3a — empty state renders when engagements list is empty', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
const tok = await adminToken();
|
||||
await deleteAllEngagements(tok);
|
||||
await page.goto('/engagements');
|
||||
await expect(page.getByRole('heading', { name: /no engagements yet/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-5.3b — loading state renders while engagements list is in-flight', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
|
||||
// Stall the list endpoint so the LoadingState becomes visible.
|
||||
const handler = async (route: import('@playwright/test').Route) => {
|
||||
const url = route.request().url();
|
||||
if (/\/api\/engagements(\?.*)?$/.test(url)) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
try {
|
||||
await route.continue();
|
||||
} catch {
|
||||
/* route may have been torn down by the time we resume */
|
||||
}
|
||||
};
|
||||
await page.route('**/api/engagements**', handler);
|
||||
|
||||
const navPromise = page.goto('/engagements');
|
||||
await expect(page.getByText(/loading engagements/i)).toBeVisible({ timeout: 3_000 });
|
||||
await navPromise;
|
||||
await page.unroute('**/api/engagements**', handler);
|
||||
});
|
||||
|
||||
test('AC-5.3c — error state renders when the engagements list returns 500', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
|
||||
const handler = async (route: import('@playwright/test').Route) => {
|
||||
const url = route.request().url();
|
||||
if (/\/api\/engagements(\?.*)?$/.test(url)) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Forced failure' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await route.continue();
|
||||
} catch {
|
||||
/* route teardown race — harmless */
|
||||
}
|
||||
};
|
||||
await page.route('**/api/engagements**', handler);
|
||||
|
||||
await page.goto('/engagements');
|
||||
await expect(page.getByTestId('error-state')).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.getByTestId('error-state')).toContainText(/forced failure/i);
|
||||
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
|
||||
await page.unroute('**/api/engagements**', handler);
|
||||
});
|
||||
});
|
||||
117
e2e/tests/us6-deployment.spec.ts
Normal file
117
e2e/tests/us6-deployment.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* US-6 — deployment via Makefile + Docker.
|
||||
*
|
||||
* These checks are infrastructure-level: they shell out to `make` and the
|
||||
* container runtime to assert the build/run targets behave correctly and
|
||||
* the SQLite volume survives a restart.
|
||||
*
|
||||
* The container is expected to already be `up` when the suite starts (the
|
||||
* harness runs `make build && make start && make create-admin` before
|
||||
* Playwright). So `AC-6.1`/`AC-6.2` are verified via image presence + HTTP
|
||||
* smoke; `AC-6.3` exercises stop/restart/logs; `AC-6.4` writes a row, restarts,
|
||||
* and re-reads it; `AC-6.5` confirms the test targets exist as Makefile rules.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteAllEngagements,
|
||||
listEngagements,
|
||||
waitForHealth,
|
||||
} from '../fixtures/api';
|
||||
|
||||
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
|
||||
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
|
||||
const IMAGE = process.env.MIMIC_IMAGE ?? 'mimic:latest';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '../..');
|
||||
|
||||
function run(cmd: string, opts: { ignoreFail?: boolean } = {}): { status: number; out: string } {
|
||||
try {
|
||||
const out = execSync(cmd, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return { status: 0, out };
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
const out =
|
||||
(typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '') +
|
||||
(typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '');
|
||||
if (opts.ignoreFail) return { status: err.status ?? -1, out };
|
||||
throw new Error(`command failed (${err.status}): ${cmd}\n${out}`);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US-6 — deployment via Docker + Makefile', () => {
|
||||
test('AC-6.1 — image mimic:latest exists (built by make build)', () => {
|
||||
const r = run(`${RUNTIME} images --format "{{.Repository}}:{{.Tag}}"`);
|
||||
expect(r.out).toMatch(new RegExp(IMAGE.replace(/[.:]/g, '\\$&')));
|
||||
});
|
||||
|
||||
test('AC-6.2 — container responds on http://localhost:5000 (front + /api/health)', async () => {
|
||||
await waitForHealth(5_000);
|
||||
// Frontend is served at "/". Use 127.0.0.1 explicitly so envs where
|
||||
// `localhost` resolves to ::1 (where the container port isn't bound)
|
||||
// don't break this contract test.
|
||||
const base = (process.env.MIMIC_BASE_URL ?? 'http://127.0.0.1:5000').replace(/\/$/, '');
|
||||
const html = run(`curl -fsS ${base}/`).out;
|
||||
expect(html).toMatch(/<!doctype html>/i);
|
||||
expect(html.toLowerCase()).toMatch(/mimic/);
|
||||
});
|
||||
|
||||
test('AC-6.3 — make stop / make restart / make logs are well-formed targets', () => {
|
||||
// Don't actually stop the container mid-suite — that would tear down
|
||||
// the other tests. Instead, verify the Makefile rules exist and are
|
||||
// syntactically valid by asking make for their recipes via `--just-print`.
|
||||
// `make restart` expands to `make stop && make start`, so we look for
|
||||
// those sub-commands instead of a literal "restart" token.
|
||||
const dry = run('make --dry-run --no-print-directory stop restart logs');
|
||||
expect(dry.out).toMatch(new RegExp(`${RUNTIME} stop ${CONTAINER}`));
|
||||
expect(dry.out).toMatch(/make stop && make start/);
|
||||
expect(dry.out).toMatch(new RegExp(`${RUNTIME} logs -f ${CONTAINER}`));
|
||||
|
||||
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
|
||||
expect(makefile).toMatch(/^stop:/m);
|
||||
expect(makefile).toMatch(/^restart:/m);
|
||||
expect(makefile).toMatch(/^logs:/m);
|
||||
});
|
||||
|
||||
test('AC-6.4 — SQLite persists across container restart (named volume mimic-data)', async () => {
|
||||
const token = await adminToken();
|
||||
// Seed a unique engagement.
|
||||
const marker = `AC-6.4-persistence-${Date.now()}`;
|
||||
await createEngagement(token, { name: marker, start_date: '2026-12-01' });
|
||||
|
||||
// Restart the container (NOT make restart, since that would also rm —
|
||||
// we do `runtime restart` which keeps the same container + volume).
|
||||
run(`${RUNTIME} restart ${CONTAINER}`);
|
||||
await waitForHealth(20_000);
|
||||
|
||||
const token2 = await adminToken();
|
||||
const items = await listEngagements(token2);
|
||||
const found = items.find((i) => i.name === marker);
|
||||
expect(found, `engagement seeded before restart should survive`).toBeTruthy();
|
||||
|
||||
// Cleanup.
|
||||
await deleteAllEngagements(token2);
|
||||
});
|
||||
|
||||
test('AC-6.5 — make test-backend / test-frontend / test-e2e are defined', () => {
|
||||
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
|
||||
expect(makefile).toMatch(/^test-backend:/m);
|
||||
expect(makefile).toMatch(/^test-frontend:/m);
|
||||
expect(makefile).toMatch(/^test-e2e:/m);
|
||||
|
||||
// Dry-run them to make sure the recipes are syntactically valid.
|
||||
const dry = run('make --dry-run --no-print-directory test-backend test-frontend test-e2e');
|
||||
expect(dry.out).toMatch(/pytest/);
|
||||
expect(dry.out).toMatch(/npm run test/);
|
||||
expect(dry.out).toMatch(/playwright test/);
|
||||
});
|
||||
});
|
||||
18
e2e/tsconfig.json
Normal file
18
e2e/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "@playwright/test"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fixtures/*": ["fixtures/*"],
|
||||
"@helpers/*": ["helpers/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
20
frontend/.eslintrc.cjs
Normal file
20
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2022: true, node: true },
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
},
|
||||
ignorePatterns: ['dist', 'node_modules', 'coverage', '*.config.js', '*.config.cjs'],
|
||||
};
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mimic — BAS</title>
|
||||
</head>
|
||||
<body class="bg-canvas text-ink">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7532
frontend/package-lock.json
generated
Normal file
7532
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "mimic-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.1",
|
||||
"@typescript-eslint/parser": "^8.8.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#024ad8"/><text x="50%" y="58%" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="38" font-weight="700" fill="#fff">M</text></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
47
frontend/src/App.tsx
Normal file
47
frontend/src/App.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { EngagementsListPage } from '@/pages/EngagementsListPage';
|
||||
import { EngagementFormPage } from '@/pages/EngagementFormPage';
|
||||
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
|
||||
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||
|
||||
/**
|
||||
* Router. Auth + role gates handled by <ProtectedRoute />.
|
||||
* Default `/` redirects to /engagements (guarded — kicks to /login if no token).
|
||||
*/
|
||||
export function App(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* All authenticated routes share the Layout chrome. */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/engagements" replace />} />
|
||||
<Route path="/engagements" element={<EngagementsListPage />} />
|
||||
<Route path="/engagements/:id" element={<EngagementDetailPage />} />
|
||||
|
||||
{/* redteam + admin write actions */}
|
||||
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
|
||||
<Route path="/engagements/new" element={<EngagementFormPage />} />
|
||||
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
|
||||
</Route>
|
||||
|
||||
{/* admin-only routes */}
|
||||
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||
<Route path="/admin/users" element={<UsersAdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
frontend/src/api/auth.ts
Normal file
16
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { apiClient } from './client';
|
||||
import type { LoginResponse, User } from './types';
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const { data } = await apiClient.post<LoginResponse>('/auth/login', { username, password });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiClient.post('/auth/logout');
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/auth/me');
|
||||
return data;
|
||||
}
|
||||
69
frontend/src/api/client.ts
Normal file
69
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mimic.token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
memoryToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (memoryToken) return memoryToken;
|
||||
memoryToken = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
return memoryToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks the auth/toast layer registers so the interceptor can react
|
||||
* to 401 (purge + redirect to /login + toast).
|
||||
*/
|
||||
type UnauthorizedHandler = () => void;
|
||||
let onUnauthorized: UnauthorizedHandler | null = null;
|
||||
|
||||
export function registerUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
setToken(null);
|
||||
onUnauthorized?.();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract a user-facing message from a thrown axios error.
|
||||
* Backend uses {error: "<message>"} shape.
|
||||
*/
|
||||
export function extractApiError(err: unknown, fallback = 'Une erreur est survenue'): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const data = err.response?.data as { error?: string } | undefined;
|
||||
if (data?.error) return data.error;
|
||||
if (err.message) return err.message;
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
29
frontend/src/api/engagements.ts
Normal file
29
frontend/src/api/engagements.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { apiClient } from './client';
|
||||
import type { Engagement, EngagementInput } from './types';
|
||||
|
||||
export async function listEngagements(): Promise<Engagement[]> {
|
||||
const { data } = await apiClient.get<Engagement[]>('/engagements');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchEngagement(id: number): Promise<Engagement> {
|
||||
const { data } = await apiClient.get<Engagement>(`/engagements/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createEngagement(input: EngagementInput): Promise<Engagement> {
|
||||
const { data } = await apiClient.post<Engagement>('/engagements', input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchEngagement(
|
||||
id: number,
|
||||
input: Partial<EngagementInput>,
|
||||
): Promise<Engagement> {
|
||||
const { data } = await apiClient.patch<Engagement>(`/engagements/${id}`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteEngagement(id: number): Promise<void> {
|
||||
await apiClient.delete(`/engagements/${id}`);
|
||||
}
|
||||
54
frontend/src/api/types.ts
Normal file
54
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type Role = 'admin' | 'redteam' | 'soc';
|
||||
|
||||
export type EngagementStatus = 'planned' | 'active' | 'closed';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
user: Pick<User, 'id' | 'username' | 'role'>;
|
||||
}
|
||||
|
||||
export interface EngagementCreatedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
status: EngagementStatus;
|
||||
created_at: string;
|
||||
created_by: EngagementCreatedBy;
|
||||
}
|
||||
|
||||
export interface EngagementInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
status?: EngagementStatus;
|
||||
}
|
||||
|
||||
export interface UserCreateInput {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface UserPatchInput {
|
||||
role?: Role;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
21
frontend/src/api/users.ts
Normal file
21
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiClient } from './client';
|
||||
import type { User, UserCreateInput, UserPatchInput } from './types';
|
||||
|
||||
export async function listUsers(): Promise<User[]> {
|
||||
const { data } = await apiClient.get<User[]>('/users');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createUser(input: UserCreateInput): Promise<User> {
|
||||
const { data } = await apiClient.post<User>('/users', input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchUser(id: number, input: UserPatchInput): Promise<User> {
|
||||
const { data } = await apiClient.patch<User>(`/users/${id}`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number): Promise<void> {
|
||||
await apiClient.delete(`/users/${id}`);
|
||||
}
|
||||
20
frontend/src/components/EmptyState.tsx
Normal file
20
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="empty-state"
|
||||
className="card-product flex flex-col items-start gap-md border border-hairline"
|
||||
>
|
||||
<h2 className="text-[24px] font-medium text-ink">{title}</h2>
|
||||
{description ? <p className="text-[16px] text-charcoal">{description}</p> : null}
|
||||
{action ? <div className="pt-xs">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ErrorState.tsx
Normal file
23
frontend/src/components/ErrorState.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="error-state"
|
||||
className="card-product border border-bloom-deep/20 flex flex-col items-start gap-md"
|
||||
>
|
||||
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
|
||||
<p className="text-[16px] text-charcoal">{message}</p>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn-outline" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/FormField.tsx
Normal file
63
frontend/src/components/FormField.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react';
|
||||
|
||||
interface BaseProps {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
error?: string | null;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({ label, htmlFor, error, hint, required, children }: BaseProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-xs">
|
||||
<label htmlFor={htmlFor} className="text-[14px] font-medium text-ink">
|
||||
{label}
|
||||
{required ? <span className="text-bloom-deep ml-1">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
{error ? (
|
||||
<span role="alert" className="text-[12px] text-bloom-deep">
|
||||
{error}
|
||||
</span>
|
||||
) : hint ? (
|
||||
<span className="text-[12px] text-graphite">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>): JSX.Element {
|
||||
return <input {...props} className={`text-input ${props.className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>): JSX.Element {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={`text-input min-h-[112px] py-sm ${props.className ?? ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<InputHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
export function Select({ options, className, ...rest }: SelectProps): JSX.Element {
|
||||
return (
|
||||
<select {...rest} className={`text-input ${className ?? ''}`}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/Layout.tsx
Normal file
91
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* Top utility strip (ink) + main nav (canvas).
|
||||
* Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app.
|
||||
*/
|
||||
export function Layout(): JSX.Element {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-canvas">
|
||||
{/* utility-strip — ink slab, fine print */}
|
||||
<div className="bg-ink text-ink-on text-[14px] h-9 flex items-center">
|
||||
<div className="mx-auto w-full max-w-page px-xl flex items-center justify-between">
|
||||
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||
{user ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<span className="text-[12px] uppercase tracking-[0.5px] text-steel">
|
||||
{user.role}
|
||||
</span>
|
||||
<span className="text-[14px]">{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-[14px] underline-offset-2 hover:underline"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* nav-bar-top — canvas with hairline */}
|
||||
<header className="bg-canvas border-b border-hairline">
|
||||
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
|
||||
<span className="text-[20px] font-medium tracking-tight">Mimic</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-md">
|
||||
<NavLink
|
||||
to="/engagements"
|
||||
className={({ isActive }) =>
|
||||
`text-[16px] py-2 px-md ${
|
||||
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Engagements
|
||||
</NavLink>
|
||||
{isAdmin ? (
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className={({ isActive }) =>
|
||||
`text-[16px] py-2 px-md ${
|
||||
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Users
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* page body */}
|
||||
<main className="flex-1 bg-canvas">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xxl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* footer — ink slab close */}
|
||||
<footer className="bg-ink text-ink-on">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-steel">
|
||||
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/LoadingState.tsx
Normal file
13
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function LoadingState({ label = 'Loading…' }: { label?: string }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="loading-state"
|
||||
className="flex items-center justify-center py-section text-graphite text-[16px]"
|
||||
>
|
||||
<span className="inline-block h-2 w-2 rounded-pill bg-primary animate-pulse mr-sm" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ProtectedRoute.tsx
Normal file
51
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { Role } from '@/api/types';
|
||||
import { LoadingState } from './LoadingState';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
/** Allowed roles. If omitted, any authenticated user passes. */
|
||||
roles?: Role[];
|
||||
/** Where to send users who lack the required role. */
|
||||
redirectOnRoleDenied?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate component: handles auth + role checks before rendering nested routes.
|
||||
* - No token / no user → /login
|
||||
* - Wrong role → redirect + "Accès refusé" toast (AC-3.7)
|
||||
*/
|
||||
export function ProtectedRoute({
|
||||
roles,
|
||||
redirectOnRoleDenied = '/engagements',
|
||||
}: ProtectedRouteProps): JSX.Element {
|
||||
const { user, status } = useAuth();
|
||||
const { push } = useToast();
|
||||
const location = useLocation();
|
||||
|
||||
const roleDenied = Boolean(
|
||||
status === 'authenticated' && user && roles && !roles.includes(user.role),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (roleDenied) {
|
||||
push('Accès refusé', 'error');
|
||||
}
|
||||
}, [roleDenied, push]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return <LoadingState label="Loading session…" />;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated' || !user) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
if (roleDenied) {
|
||||
return <Navigate to={redirectOnRoleDenied} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
27
frontend/src/components/StatusBadge.tsx
Normal file
27
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { EngagementStatus } from '@/api/types';
|
||||
|
||||
const LABELS: Record<EngagementStatus, string> = {
|
||||
planned: 'Planned',
|
||||
active: 'Active',
|
||||
closed: 'Closed',
|
||||
};
|
||||
|
||||
const STYLES: Record<EngagementStatus, string> = {
|
||||
// Outlined ink for planned (neutral), filled primary for active (engagement live),
|
||||
// outlined steel for closed (muted). Stays within DESIGN.md palette.
|
||||
planned: 'bg-canvas text-ink border border-ink',
|
||||
active: 'bg-primary text-ink-on',
|
||||
closed: 'bg-cloud text-graphite border border-hairline',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
|
||||
data-testid="status-badge"
|
||||
data-status={status}
|
||||
>
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/Toast.tsx
Normal file
47
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
/**
|
||||
* Stack of toast notifications anchored bottom-right.
|
||||
* Pure DESIGN.md surfaces: rounded-xl, soft-lift, ink slab for errors.
|
||||
*/
|
||||
export function ToastViewport(): JSX.Element {
|
||||
const { toasts, dismiss } = useToast();
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
||||
>
|
||||
{toasts.map((t) => {
|
||||
const isError = t.kind === 'error';
|
||||
const isSuccess = t.kind === 'success';
|
||||
const surface = isError
|
||||
? 'bg-ink text-ink-on'
|
||||
: isSuccess
|
||||
? 'bg-primary text-ink-on'
|
||||
: 'bg-canvas text-ink border border-hairline';
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
data-testid="toast"
|
||||
data-kind={t.kind}
|
||||
className={`pointer-events-auto rounded-xl px-md py-sm shadow-soft-lift text-[14px] leading-[1.4] ${surface}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-sm">
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="text-current opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/hooks/useAuth.tsx
Normal file
112
frontend/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, fetchMe } from '@/api/auth';
|
||||
import { getToken, registerUnauthorizedHandler, setToken } from '@/api/client';
|
||||
import type { Role, User } from '@/api/types';
|
||||
import { useToast } from './useToast';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
status: 'loading' | 'authenticated' | 'unauthenticated';
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
isRedteam: boolean;
|
||||
isSoc: boolean;
|
||||
canEditEngagements: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
|
||||
const { push } = useToast();
|
||||
|
||||
// Bootstrap: if a token exists in storage, try to recover the session.
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchMe()
|
||||
.then((u) => {
|
||||
if (cancelled) return;
|
||||
setUser(u);
|
||||
setStatus('authenticated');
|
||||
})
|
||||
.catch(() => {
|
||||
// 401 interceptor already purges token + triggers redirect/toast.
|
||||
if (cancelled) return;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Register the global 401 handler once.
|
||||
useEffect(() => {
|
||||
registerUnauthorizedHandler(() => {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
push('Session expirée', 'error');
|
||||
// Hard redirect so any in-flight query state is cleared.
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.assign('/login');
|
||||
}
|
||||
});
|
||||
}, [push]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const resp = await apiLogin(username, password);
|
||||
setToken(resp.access_token);
|
||||
// Hydrate full user (with created_at) via /me.
|
||||
const me = await fetchMe();
|
||||
setUser(me);
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Logout is best-effort server-side; the client purge below is what matters.
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const role: Role | undefined = user?.role;
|
||||
return {
|
||||
user,
|
||||
status,
|
||||
login,
|
||||
logout,
|
||||
isAdmin: role === 'admin',
|
||||
isRedteam: role === 'redteam',
|
||||
isSoc: role === 'soc',
|
||||
canEditEngagements: role === 'admin' || role === 'redteam',
|
||||
};
|
||||
}, [user, status, login, logout]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
50
frontend/src/hooks/useEngagements.ts
Normal file
50
frontend/src/hooks/useEngagements.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
fetchEngagement,
|
||||
listEngagements,
|
||||
patchEngagement,
|
||||
} from '@/api/engagements';
|
||||
import type { EngagementInput } from '@/api/types';
|
||||
|
||||
const KEY = ['engagements'] as const;
|
||||
|
||||
export function useEngagementsList() {
|
||||
return useQuery({ queryKey: KEY, queryFn: listEngagements });
|
||||
}
|
||||
|
||||
export function useEngagement(id: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY, id],
|
||||
queryFn: () => fetchEngagement(id as number),
|
||||
enabled: typeof id === 'number' && !Number.isNaN(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEngagement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: EngagementInput) => createEngagement(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchEngagement(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: Partial<EngagementInput>) => patchEngagement(id, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEngagement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteEngagement(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
58
frontend/src/hooks/useToast.tsx
Normal file
58
frontend/src/hooks/useToast.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type ToastKind = 'info' | 'success' | 'error';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
push: (message: string, kind?: ToastKind) => void;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const DEFAULT_DURATION_MS = 4000;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const counterRef = useRef(0);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(message: string, kind: ToastKind = 'info') => {
|
||||
counterRef.current += 1;
|
||||
const id = counterRef.current;
|
||||
setToasts((prev) => [...prev, { id, message, kind }]);
|
||||
setTimeout(() => dismiss(id), DEFAULT_DURATION_MS);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
||||
|
||||
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToast must be used inside ToastProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
33
frontend/src/hooks/useUsers.ts
Normal file
33
frontend/src/hooks/useUsers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createUser, deleteUser, listUsers, patchUser } from '@/api/users';
|
||||
import type { UserCreateInput, UserPatchInput } from '@/api/types';
|
||||
|
||||
const KEY = ['users'] as const;
|
||||
|
||||
export function useUsersList() {
|
||||
return useQuery({ queryKey: KEY, queryFn: listUsers });
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: UserCreateInput) => createUser(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: UserPatchInput }) => patchUser(id, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteUser(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ToastProvider } from './hooks/useToast';
|
||||
import './styles/index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('Missing #root element');
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
84
frontend/src/pages/EngagementDetailPage.tsx
Normal file
84
frontend/src/pages/EngagementDetailPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagement } from '@/hooks/useEngagements';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
export function EngagementDetailPage(): JSX.Element {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const { canEditEngagements } = useAuth();
|
||||
|
||||
const detail = useEngagement(numericId);
|
||||
|
||||
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!detail.data) return <ErrorState message="Engagement not found" />;
|
||||
|
||||
const eng = detail.data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<Link to="/engagements" className="btn-text-link text-[14px]">
|
||||
← Back to engagements
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none">{eng.name}</h1>
|
||||
<div className="flex items-center gap-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
<span className="text-[14px] text-graphite">
|
||||
Created by <span className="text-ink">{eng.created_by.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
||||
Edit
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
|
||||
<dl className="grid grid-cols-2 gap-md text-[14px]">
|
||||
<dt className="text-graphite">Start date</dt>
|
||||
<dd className="text-ink">{eng.start_date}</dd>
|
||||
<dt className="text-graphite">End date</dt>
|
||||
<dd className="text-ink">{eng.end_date ?? '—'}</dd>
|
||||
<dt className="text-graphite">Status</dt>
|
||||
<dd className="text-ink capitalize">{eng.status}</dd>
|
||||
<dt className="text-graphite">Created at</dt>
|
||||
<dd className="text-ink">{eng.created_at}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Description</h2>
|
||||
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||
{eng.description?.trim() ? eng.description : 'No description provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sprint 2 placeholder per AC-4.9 */}
|
||||
<section className="bg-ink text-ink-on rounded-xl p-xxl">
|
||||
<h2 className="text-[32px] font-medium leading-none">Simulations</h2>
|
||||
<p className="text-[16px] mt-sm text-steel">
|
||||
Simulations à venir au Sprint 2 — tracking of red team tests and SOC detection coverage
|
||||
will live here.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
frontend/src/pages/EngagementFormPage.tsx
Normal file
219
frontend/src/pages/EngagementFormPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { EngagementInput, EngagementStatus } from '@/api/types';
|
||||
import {
|
||||
useCreateEngagement,
|
||||
useEngagement,
|
||||
usePatchEngagement,
|
||||
} from '@/hooks/useEngagements';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: EngagementStatus;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
status: 'planned',
|
||||
};
|
||||
|
||||
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!state.name.trim()) errors.name = 'Name is required';
|
||||
if (!state.start_date) errors.start_date = 'Start date is required';
|
||||
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||
errors.end_date = 'End date must be on or after start date';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function EngagementFormPage(): JSX.Element {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const editing = Boolean(id);
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
|
||||
const detail = useEngagement(editing ? numericId : undefined);
|
||||
const createMutation = useCreateEngagement();
|
||||
const patchMutation = usePatchEngagement(numericId ?? 0);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Hydrate edit form when data arrives.
|
||||
useEffect(() => {
|
||||
if (editing && detail.data) {
|
||||
setForm({
|
||||
name: detail.data.name,
|
||||
description: detail.data.description ?? '',
|
||||
start_date: detail.data.start_date,
|
||||
end_date: detail.data.end_date ?? '',
|
||||
status: detail.data.status,
|
||||
});
|
||||
}
|
||||
}, [editing, detail.data]);
|
||||
|
||||
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (editing && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
const v = validate(form);
|
||||
setErrors(v);
|
||||
if (Object.keys(v).length > 0) return;
|
||||
|
||||
const payload: EngagementInput = {
|
||||
name: form.name.trim(),
|
||||
start_date: form.start_date,
|
||||
status: form.status,
|
||||
};
|
||||
if (form.description.trim()) payload.description = form.description.trim();
|
||||
// PATCH with null clears end_date; POST with omitted leaves it null
|
||||
if (editing) {
|
||||
// Always include end_date for edit: '' → null to clear, otherwise value
|
||||
payload.end_date = form.end_date === '' ? null : form.end_date;
|
||||
} else if (form.end_date) {
|
||||
payload.end_date = form.end_date;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editing && numericId) {
|
||||
await patchMutation.mutateAsync(payload);
|
||||
push('Engagement updated', 'success');
|
||||
navigate(`/engagements/${numericId}`);
|
||||
} else {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push('Engagement created', 'success');
|
||||
navigate(`/engagements/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not save engagement'));
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = createMutation.isPending || patchMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-2xl">
|
||||
<header>
|
||||
<h1 className="text-[44px] font-medium leading-none">
|
||||
{editing ? 'Edit engagement' : 'New engagement'}
|
||||
</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{editing
|
||||
? 'Update the engagement metadata.'
|
||||
: 'Create a new red team mission to host simulations.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||
<TextInput
|
||||
id="eng-name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="eng-description">
|
||||
<TextArea
|
||||
id="eng-description"
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<FormField
|
||||
label="Start date"
|
||||
htmlFor="eng-start"
|
||||
required
|
||||
error={errors.start_date}
|
||||
>
|
||||
<TextInput
|
||||
id="eng-start"
|
||||
type="date"
|
||||
name="start_date"
|
||||
value={form.start_date}
|
||||
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="End date"
|
||||
htmlFor="eng-end"
|
||||
hint="Leave empty to clear / leave open-ended"
|
||||
error={errors.end_date}
|
||||
>
|
||||
<TextInput
|
||||
id="eng-end"
|
||||
type="date"
|
||||
name="end_date"
|
||||
value={form.end_date}
|
||||
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Status" htmlFor="eng-status" required>
|
||||
<Select
|
||||
id="eng-status"
|
||||
name="status"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-md pt-sm">
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
|
||||
</button>
|
||||
<Link
|
||||
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
|
||||
className="btn-outline-ink"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/EngagementsListPage.tsx
Normal file
126
frontend/src/pages/EngagementsListPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Engagement } from '@/api/types';
|
||||
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function EngagementsListPage(): JSX.Element {
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementsList();
|
||||
const { canEditEngagements } = useAuth();
|
||||
const { push } = useToast();
|
||||
const deleteMutation = useDeleteEngagement();
|
||||
|
||||
const onDelete = async (eng: Engagement) => {
|
||||
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(eng.id);
|
||||
push('Engagement supprimé', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Suppression impossible'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[44px] font-medium leading-none">Engagements</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Red team missions and their lifecycle status.
|
||||
</p>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
New engagement
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No engagements yet"
|
||||
description="Create your first engagement to start tracking red team missions."
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
Create engagement
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Name</th>
|
||||
<th className="px-xl py-md">Status</th>
|
||||
<th className="px-xl py-md">Start</th>
|
||||
<th className="px-xl py-md">End</th>
|
||||
<th className="px-xl py-md">Created by</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((eng) => (
|
||||
<tr key={eng.id} className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md">
|
||||
<Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline">
|
||||
{eng.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal">{formatDate(eng.start_date)}</td>
|
||||
<td className="px-xl py-md text-charcoal">{formatDate(eng.end_date)}</td>
|
||||
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td>
|
||||
<td className="px-xl py-md text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
|
||||
View
|
||||
</Link>
|
||||
{canEditEngagements ? (
|
||||
<>
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep"
|
||||
onClick={() => onDelete(eng)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/LoginPage.tsx
Normal file
90
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { FormField, TextInput } from '@/components/FormField';
|
||||
|
||||
interface LocationState {
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export function LoginPage(): JSX.Element {
|
||||
const { login, status, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const fromPath = (location.state as LocationState | null)?.from;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Already authenticated → bounce to engagements (e.g. user navigates back to /login).
|
||||
// Returning <Navigate> instead of calling navigate() during render avoids the
|
||||
// "Cannot update a component while rendering a different component" warning.
|
||||
if (status === 'authenticated' && user) {
|
||||
return <Navigate to={fromPath ?? '/engagements'} replace />;
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(fromPath ?? '/engagements', { replace: true });
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, 'Invalid credentials'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cloud flex items-center justify-center px-md">
|
||||
<div className="w-full max-w-md card-product flex flex-col gap-lg">
|
||||
{/* Chevron echo of the brand mark */}
|
||||
<div className="flex items-center gap-sm">
|
||||
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
|
||||
<h1 className="text-[32px] font-medium leading-none">Mimic</h1>
|
||||
</div>
|
||||
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-md">
|
||||
<FormField label="Username" htmlFor="login-username" required>
|
||||
<TextInput
|
||||
id="login-username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password" required>
|
||||
<TextInput
|
||||
id="login-password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" data-testid="login-error" className="text-[14px] text-bloom-deep">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
frontend/src/pages/UsersAdminPage.tsx
Normal file
276
frontend/src/pages/UsersAdminPage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Fragment, useState, type FormEvent } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Role, User } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import {
|
||||
useCreateUser,
|
||||
useDeleteUser,
|
||||
usePatchUser,
|
||||
useUsersList,
|
||||
} from '@/hooks/useUsers';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { FormField, Select, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
|
||||
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'redteam', label: 'Red Team' },
|
||||
{ value: 'soc', label: 'SOC' },
|
||||
];
|
||||
|
||||
interface CreateFormState {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
||||
|
||||
export function UsersAdminPage(): JSX.Element {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { push } = useToast();
|
||||
const list = useUsersList();
|
||||
const createMutation = useCreateUser();
|
||||
const patchMutation = usePatchUser();
|
||||
const deleteMutation = useDeleteUser();
|
||||
|
||||
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Per-row password reset state. Only one row open at a time.
|
||||
const [resetOpen, setResetOpen] = useState<number | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
if (createForm.password.length < 8) {
|
||||
setCreateError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(createForm);
|
||||
setCreateForm(EMPTY_CREATE);
|
||||
push('User created', 'success');
|
||||
} catch (err) {
|
||||
setCreateError(extractApiError(err, 'Could not create user'));
|
||||
}
|
||||
};
|
||||
|
||||
const onRoleChange = async (u: User, role: Role) => {
|
||||
if (u.role === role) return;
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
||||
push(`Role updated for ${u.username}`, 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not update role'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onResetPassword = async (u: User, e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (resetPassword.length < 8) {
|
||||
push('Password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
||||
push(`Password reset for ${u.username}`, 'success');
|
||||
setResetOpen(null);
|
||||
setResetPassword('');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not reset password'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (u: User) => {
|
||||
if (currentUser?.id === u.id) {
|
||||
push('You cannot delete your own account', 'error');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete user "${u.username}"?`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(u.id);
|
||||
push('User deleted', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete user'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[44px] font-medium leading-none">User accounts</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Manage local accounts. Admins can create new red team or SOC analysts.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="card-product flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
||||
<FormField label="Username" htmlFor="new-username" required>
|
||||
<TextInput
|
||||
id="new-username"
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="new-password" required hint="≥ 8 characters">
|
||||
<TextInput
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Role" htmlFor="new-role" required>
|
||||
<Select
|
||||
id="new-role"
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{createError}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">All accounts</h2>
|
||||
|
||||
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
|
||||
{list.isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(list.error, 'Could not load users')}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No users yet"
|
||||
description="Create the first account using the form above."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Username</th>
|
||||
<th className="px-xl py-md">Role</th>
|
||||
<th className="px-xl py-md">Created</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.map((u) => {
|
||||
const isSelf = currentUser?.id === u.id;
|
||||
return (
|
||||
// Fragment must carry the key — `<>` cannot, which broke
|
||||
// per-row reconciliation (reset-password state leaked across rows).
|
||||
<Fragment key={u.id}>
|
||||
<tr className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md font-medium text-ink">
|
||||
{u.username}
|
||||
{isSelf ? (
|
||||
<span className="ml-sm text-[12px] text-graphite uppercase tracking-[0.5px]">
|
||||
(you)
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<Select
|
||||
value={u.role}
|
||||
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
||||
options={ROLE_OPTIONS}
|
||||
aria-label={`Change role for ${u.username}`}
|
||||
disabled={patchMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal">{u.created_at}</td>
|
||||
<td className="px-xl py-md text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link"
|
||||
onClick={() => {
|
||||
setResetOpen(resetOpen === u.id ? null : u.id);
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep disabled:text-steel"
|
||||
disabled={isSelf || deleteMutation.isPending}
|
||||
onClick={() => onDelete(u)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{resetOpen === u.id ? (
|
||||
<tr className="border-b border-hairline last:border-0 bg-cloud">
|
||||
<td colSpan={4} className="px-xl py-md">
|
||||
<form
|
||||
onSubmit={(e) => onResetPassword(u, e)}
|
||||
className="flex items-end gap-md"
|
||||
>
|
||||
<FormField
|
||||
label={`New password for ${u.username}`}
|
||||
htmlFor={`reset-${u.id}`}
|
||||
hint="≥ 8 characters"
|
||||
>
|
||||
<TextInput
|
||||
id={`reset-${u.id}`}
|
||||
type="password"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary">
|
||||
Save password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={() => {
|
||||
setResetOpen(null);
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
frontend/src/styles/fonts.css
Normal file
6
frontend/src/styles/fonts.css
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Inter Variable — bundled locally via @fontsource-variable/inter.
|
||||
* NO remote CDN / Google Fonts loading at runtime.
|
||||
* Forma DJR Micro substitute per DESIGN.md §Note on Font Substitutes.
|
||||
*/
|
||||
@import '@fontsource-variable/inter/index.css';
|
||||
94
frontend/src/styles/index.css
Normal file
94
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,94 @@
|
||||
@import './fonts.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-ink font-sans antialiased;
|
||||
/* DESIGN.md: body line-height 1.4 when substituting Inter */
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
/* Compensate for Inter being slightly narrower than Forma DJR Micro (~3%) */
|
||||
:root {
|
||||
font-size: 16.5px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* DESIGN.md component recipes.
|
||||
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
||||
*/
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center bg-primary text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
@apply bg-primary-deep;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ink {
|
||||
@apply inline-flex items-center justify-center bg-ink text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-ink:hover {
|
||||
@apply bg-ink-soft;
|
||||
}
|
||||
.btn-ink:disabled {
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply inline-flex items-center justify-center bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-outline:hover {
|
||||
@apply bg-primary-soft;
|
||||
}
|
||||
|
||||
.btn-outline-ink {
|
||||
@apply inline-flex items-center justify-center bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-outline-ink:hover {
|
||||
@apply bg-cloud;
|
||||
}
|
||||
|
||||
.btn-text-link {
|
||||
@apply inline-flex items-center text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply block w-full bg-canvas text-ink rounded-md border border-steel px-md py-sm h-11 text-[16px] leading-[1.38] focus:outline-none focus:border-ink;
|
||||
}
|
||||
|
||||
.card-product {
|
||||
@apply bg-canvas rounded-xl p-xl shadow-soft-lift;
|
||||
}
|
||||
|
||||
.badge-pill-ink {
|
||||
@apply inline-flex items-center bg-ink text-ink-on rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||
}
|
||||
|
||||
.badge-pill-outline {
|
||||
@apply inline-flex items-center bg-canvas text-ink border border-ink rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||
}
|
||||
}
|
||||
101
frontend/tailwind.config.ts
Normal file
101
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
/**
|
||||
* Tokens mirror DESIGN.md.
|
||||
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
|
||||
*/
|
||||
const config: Config = {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand & Accent
|
||||
primary: {
|
||||
DEFAULT: '#024ad8',
|
||||
bright: '#296ef9',
|
||||
deep: '#0e3191',
|
||||
soft: '#c9e0fc',
|
||||
},
|
||||
// Surface
|
||||
canvas: '#ffffff',
|
||||
paper: '#ffffff',
|
||||
cloud: '#f7f7f7',
|
||||
fog: '#e8e8e8',
|
||||
steel: '#c2c2c2',
|
||||
hairline: '#e8e8e8',
|
||||
// Text
|
||||
ink: {
|
||||
DEFAULT: '#1a1a1a',
|
||||
deep: '#000000',
|
||||
soft: '#292929',
|
||||
on: '#ffffff',
|
||||
},
|
||||
charcoal: '#3d3d3d',
|
||||
graphite: '#636363',
|
||||
// Semantic / decorative
|
||||
bloom: {
|
||||
coral: '#ff5050',
|
||||
rose: '#f9d4d2',
|
||||
deep: '#b3262b',
|
||||
wine: '#5a1313',
|
||||
},
|
||||
storm: {
|
||||
mist: '#8ebdce',
|
||||
sea: '#7fadbe',
|
||||
deep: '#356373',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
// DESIGN.md typography scale
|
||||
'display-xxl': ['72px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-xl': ['56px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-lg': ['44px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-md': ['32px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-sm': ['24px', { lineHeight: '1.17', fontWeight: '500' }],
|
||||
'display-xs': ['20px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'body-lg': ['18px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||
'body-md': ['16px', { lineHeight: '1.38', fontWeight: '400' }],
|
||||
'body-emphasis': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||
'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }],
|
||||
'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||
'link-md': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600', letterSpacing: '0.7px' }],
|
||||
},
|
||||
spacing: {
|
||||
// DESIGN.md spacing tokens (named, complement Tailwind defaults)
|
||||
xxs: '4px',
|
||||
xs: '8px',
|
||||
sm: '12px',
|
||||
md: '16px',
|
||||
lg: '20px',
|
||||
xl: '24px',
|
||||
xxl: '32px',
|
||||
section: '80px',
|
||||
},
|
||||
borderRadius: {
|
||||
// DESIGN.md radius tokens
|
||||
none: '0px',
|
||||
xs: '2px',
|
||||
sm: '3px',
|
||||
md: '4px',
|
||||
lg: '8px',
|
||||
xl: '16px',
|
||||
pill: '9999px',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
|
||||
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
|
||||
},
|
||||
maxWidth: {
|
||||
page: '1366px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
80
frontend/tests/ProtectedRoute.test.tsx
Normal file
80
frontend/tests/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { ToastProvider } from '@/hooks/useToast';
|
||||
import { AuthProvider } from '@/hooks/useAuth';
|
||||
import { setToken } from '@/api/client';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
|
||||
// Mock the auth API so AuthProvider hydrates without network.
|
||||
vi.mock('@/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchMe } from '@/api/auth';
|
||||
|
||||
function setup() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>LOGIN PAGE</div>} />
|
||||
<Route path="/engagements" element={<div>ENGAGEMENTS</div>} />
|
||||
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||
<Route path="/admin" element={<div>ADMIN AREA</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ToastViewport />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(fetchMe).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to /login', async () => {
|
||||
// No token → unauthenticated, no /me call.
|
||||
setup();
|
||||
expect(await screen.findByText('LOGIN PAGE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('admins reach the admin page', async () => {
|
||||
setToken('fake-token');
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'alice',
|
||||
role: 'admin',
|
||||
created_at: '2026-01-01',
|
||||
});
|
||||
setup();
|
||||
expect(await screen.findByText('ADMIN AREA')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('non-admins get redirected and see an access denied toast', async () => {
|
||||
setToken('fake-token');
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: 2,
|
||||
username: 'bob',
|
||||
role: 'soc',
|
||||
created_at: '2026-01-01',
|
||||
});
|
||||
setup();
|
||||
expect(await screen.findByText('ENGAGEMENTS')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toast')).toHaveTextContent('Accès refusé');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
frontend/tests/StatusBadge.test.tsx
Normal file
12
frontend/tests/StatusBadge.test.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it.each(['planned', 'active', 'closed'] as const)('renders %s label and data attr', (status) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
const badge = screen.getByTestId('status-badge');
|
||||
expect(badge).toHaveAttribute('data-status', status);
|
||||
expect(badge.textContent?.toLowerCase()).toBe(status);
|
||||
});
|
||||
});
|
||||
62
frontend/tests/Toast.test.tsx
Normal file
62
frontend/tests/Toast.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ToastProvider, useToast } from '@/hooks/useToast';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
|
||||
function Pusher() {
|
||||
const { push } = useToast();
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => push('Session expirée', 'error')}>Push session</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Toast', () => {
|
||||
it('renders pushed toasts and lets the user dismiss them manually', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Pusher />
|
||||
<ToastViewport />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /push session/i }));
|
||||
const toast = await screen.findByTestId('toast');
|
||||
expect(toast).toHaveTextContent('Session expirée');
|
||||
expect(toast).toHaveAttribute('data-kind', 'error');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('toast')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-dismisses after the timeout', async () => {
|
||||
// Override the 4s default by polling the DOM up to 6s. Real timers keep
|
||||
// user-event happy; the toast hook clears itself via setTimeout.
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Pusher />
|
||||
<ToastViewport />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /push session/i }));
|
||||
expect(await screen.findByTestId('toast')).toBeInTheDocument();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByTestId('toast')).toBeNull();
|
||||
},
|
||||
{ timeout: 6000 },
|
||||
);
|
||||
// Quiet act() warning by flushing any pending state.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}, 10_000);
|
||||
});
|
||||
106
frontend/tests/UsersAdminPage.test.tsx
Normal file
106
frontend/tests/UsersAdminPage.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
// Mock useAuth so the page sees a logged-in admin without hydrating from network.
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' } as User,
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: true,
|
||||
isRedteam: false,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const USERS: User[] = [
|
||||
{ id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
|
||||
{ id: 2, username: 'bob', role: 'redteam', created_at: '2026-02-01' },
|
||||
{ id: 3, username: 'carol', role: 'soc', created_at: '2026-03-01' },
|
||||
];
|
||||
|
||||
describe('UsersAdminPage', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/users').reply(200, USERS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('renders the list of users from the API', async () => {
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
expect(screen.getByText('carol')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a user via POST /users and refreshes the list', async () => {
|
||||
const newUser: User = { id: 4, username: 'dan', role: 'soc', created_at: '2026-04-01' };
|
||||
const postSpy = vi.fn().mockReturnValue([201, newUser]);
|
||||
mock.onPost('/users').reply((config) => postSpy(JSON.parse(config.data)));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
|
||||
await screen.findByText('alice');
|
||||
|
||||
await user.type(screen.getByLabelText(/^username/i), 'dan');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'sup3rs4fe!');
|
||||
// role default is 'redteam'; switch to 'soc' to match newUser
|
||||
await user.selectOptions(screen.getByLabelText(/^role/i), 'soc');
|
||||
|
||||
// After POST, hooks invalidate and the list refetches → return the new list
|
||||
mock.onGet('/users').reply(200, [...USERS, newUser]);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postSpy).toHaveBeenCalledWith({
|
||||
username: 'dan',
|
||||
password: 'sup3rs4fe!',
|
||||
role: 'soc',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('dan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the password reset form for the row that was clicked (fragment-key regression guard)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
|
||||
await screen.findByText('bob');
|
||||
|
||||
// The "Reset password" button for bob lives in bob's row.
|
||||
const bobRow = screen.getByText('bob').closest('tr');
|
||||
expect(bobRow).not.toBeNull();
|
||||
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /reset password/i }));
|
||||
|
||||
// The reset form for bob (and bob only) must appear.
|
||||
expect(await screen.findByLabelText(/new password for bob/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/new password for carol/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/new password for alice/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('disables the delete button on the current user own row', async () => {
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
await screen.findByText('alice');
|
||||
|
||||
const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement;
|
||||
const bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
|
||||
|
||||
expect(within(aliceRow).getByRole('button', { name: /delete/i })).toBeDisabled();
|
||||
expect(within(bobRow).getByRole('button', { name: /delete/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
57
frontend/tests/apiClient.test.tsx
Normal file
57
frontend/tests/apiClient.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient, getToken, registerUnauthorizedHandler, setToken } from '@/api/client';
|
||||
|
||||
describe('apiClient interceptors', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
it('attaches Bearer token from storage', async () => {
|
||||
setToken('abc123');
|
||||
mock.onGet('/auth/me').reply((config) => {
|
||||
expect(config.headers?.Authorization).toBe('Bearer abc123');
|
||||
return [200, { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' }];
|
||||
});
|
||||
|
||||
const resp = await apiClient.get('/auth/me');
|
||||
expect(resp.status).toBe(200);
|
||||
});
|
||||
|
||||
it('purges token and calls the registered handler on 401', async () => {
|
||||
setToken('expired');
|
||||
const handler = vi.fn();
|
||||
registerUnauthorizedHandler(handler);
|
||||
|
||||
mock.onGet('/engagements').reply(401, { error: 'token expired' });
|
||||
|
||||
await expect(apiClient.get('/engagements')).rejects.toMatchObject({
|
||||
response: { status: 401 },
|
||||
});
|
||||
|
||||
expect(getToken()).toBeNull();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset to avoid leaking into other tests.
|
||||
registerUnauthorizedHandler(() => {});
|
||||
});
|
||||
|
||||
it('leaves the token intact on non-401 errors', async () => {
|
||||
setToken('still-valid');
|
||||
mock.onGet('/users').reply(403, { error: 'forbidden' });
|
||||
|
||||
await expect(apiClient.get('/users')).rejects.toMatchObject({
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
expect(getToken()).toBe('still-valid');
|
||||
});
|
||||
});
|
||||
43
frontend/tests/states.test.tsx
Normal file
43
frontend/tests/states.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
|
||||
describe('LoadingState', () => {
|
||||
it('shows the default label', () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('shows a custom label', () => {
|
||||
render(<LoadingState label="Fetching things…" />);
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Fetching things…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ErrorState', () => {
|
||||
it('renders message and triggers retry', async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Boom" onRetry={onRetry} />);
|
||||
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
|
||||
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits retry button when no handler given', () => {
|
||||
render(<ErrorState message="Boom" />);
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title, description and action', () => {
|
||||
render(<EmptyState title="Nothing here" description="Add one" action={<button>Create</button>} />);
|
||||
const node = screen.getByTestId('empty-state');
|
||||
expect(node).toHaveTextContent('Nothing here');
|
||||
expect(node).toHaveTextContent('Add one');
|
||||
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
frontend/tests/utils.tsx
Normal file
33
frontend/tests/utils.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { ToastProvider } from '@/hooks/useToast';
|
||||
|
||||
interface WrapperOptions {
|
||||
routerProps?: MemoryRouterProps;
|
||||
}
|
||||
|
||||
export function makeQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{ routerProps, ...rtlOptions }: WrapperOptions & Omit<RenderOptions, 'wrapper'> = {},
|
||||
) {
|
||||
const client = makeQueryClient();
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return { ...render(ui, { wrapper: Wrapper, ...rtlOptions }), client };
|
||||
}
|
||||
29
frontend/tsconfig.json
Normal file
29
frontend/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client", "node", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts", "vitest.setup.ts"]
|
||||
}
|
||||
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest.setup.ts',
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
1
frontend/vitest.setup.ts
Normal file
1
frontend/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
17
pyrightconfig.json
Normal file
17
pyrightconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"venvPath": "backend",
|
||||
"venv": ".venv",
|
||||
"extraPaths": ["."],
|
||||
"include": ["backend"],
|
||||
"exclude": [
|
||||
"**/__pycache__",
|
||||
"**/.mypy_cache",
|
||||
"**/.ruff_cache",
|
||||
"**/.pytest_cache",
|
||||
"**/node_modules",
|
||||
".claude",
|
||||
"frontend/dist",
|
||||
"backend/.venv"
|
||||
],
|
||||
"reportMissingTypeStubs": false
|
||||
}
|
||||
Reference in New Issue
Block a user