feat: sprint 1 — auth + CRUD engagements

Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.

Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
  and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
  DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling

Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
  UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
  ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
  expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
  for already-authed users, Fragment-keyed admin user rows)

Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
  update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data

Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)

Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
  resolves the `backend.app.*` absolute imports

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-26 09:37:53 +02:00
parent be266d4879
commit 5104f7c429
95 changed files with 13801 additions and 5 deletions

171
e2e/fixtures/api.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* Thin axios client used by tests to seed/teardown users and engagements
* without going through the UI. The bootstrap admin is created out-of-band
* (via `make create-admin`) and logs in once to provision per-suite users.
*/
import axios, { AxiosInstance, isAxiosError } from 'axios';
export interface User {
id: number;
username: string;
role: 'admin' | 'redteam' | 'soc';
created_at: string | null;
}
export interface Engagement {
id: number;
name: string;
description: string | null;
start_date: string;
end_date: string | null;
status: 'planned' | 'active' | 'closed';
created_at: string | null;
created_by: { id: number; username: string } | null;
}
export type Role = User['role'];
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
const BOOTSTRAP_ADMIN_USER = process.env.MIMIC_BOOTSTRAP_USER ?? 'root';
const BOOTSTRAP_ADMIN_PASS = process.env.MIMIC_BOOTSTRAP_PASS ?? 'rootpass8';
export function makeClient(token?: string): AxiosInstance {
return axios.create({
baseURL: `${BASE_URL}/api`,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
validateStatus: () => true, // tests assert on status themselves
});
}
export async function login(
username: string,
password: string,
): Promise<{ token: string; user: User }> {
const client = makeClient();
const r = await client.post('/auth/login', { username, password });
if (r.status !== 200) {
throw new Error(`login(${username}) failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return { token: r.data.access_token as string, user: r.data.user as User };
}
export async function adminToken(): Promise<string> {
const { token } = await login(BOOTSTRAP_ADMIN_USER, BOOTSTRAP_ADMIN_PASS);
return token;
}
/**
* Idempotent helper: ensures a user with the given username/role exists and
* has the requested password. Returns the user record.
*
* Strategy:
* - try login: if it succeeds, we're done.
* - else: as admin, list users; if username found, PATCH password+role; else POST.
*/
export async function ensureUser(
username: string,
password: string,
role: Role,
): Promise<User> {
try {
const { user } = await login(username, password);
if (user.role !== role) {
const admin = await adminToken();
const client = makeClient(admin);
const r = await client.patch(`/users/${user.id}`, { role });
if (r.status !== 200) throw new Error(`patch role: ${r.status}`);
return r.data as User;
}
return user;
} catch {
// fall through to admin path
}
const admin = await adminToken();
const client = makeClient(admin);
const list = await client.get('/users');
if (list.status !== 200) {
throw new Error(`list users failed: ${list.status} ${JSON.stringify(list.data)}`);
}
const existing = (list.data as User[]).find((u) => u.username === username);
if (existing) {
const r = await client.patch(`/users/${existing.id}`, { password, role });
if (r.status !== 200) {
throw new Error(`patch user failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as User;
}
const r = await client.post('/users', { username, password, role });
if (r.status !== 201) {
throw new Error(`create user failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as User;
}
export async function deleteUserByUsername(token: string, username: string): Promise<void> {
const client = makeClient(token);
const list = await client.get('/users');
if (list.status !== 200) return;
const u = (list.data as User[]).find((x) => x.username === username);
if (!u) return;
await client.delete(`/users/${u.id}`);
}
export async function createEngagement(
token: string,
payload: Partial<Pick<Engagement, 'name' | 'description' | 'start_date' | 'end_date' | 'status'>>,
): Promise<Engagement> {
const client = makeClient(token);
const body = {
name: payload.name ?? 'Test Engagement',
description: payload.description,
start_date: payload.start_date ?? '2026-01-01',
end_date: payload.end_date,
status: payload.status ?? 'planned',
};
const r = await client.post('/engagements', body);
if (r.status !== 201) {
throw new Error(`create engagement failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as Engagement;
}
export async function deleteEngagement(token: string, id: number): Promise<void> {
const client = makeClient(token);
await client.delete(`/engagements/${id}`);
}
export async function listEngagements(token: string): Promise<Engagement[]> {
const client = makeClient(token);
const r = await client.get('/engagements');
if (r.status !== 200) {
throw new Error(`list engagements failed: ${r.status}`);
}
return r.data as Engagement[];
}
export async function deleteAllEngagements(token: string): Promise<void> {
const items = await listEngagements(token);
await Promise.all(items.map((e) => deleteEngagement(token, e.id)));
}
export async function waitForHealth(timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
const client = makeClient();
let lastErr: unknown = null;
while (Date.now() < deadline) {
try {
const r = await client.get('/health');
if (r.status === 200) return;
} catch (e) {
lastErr = e;
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(
`backend not healthy after ${timeoutMs}ms: ${
isAxiosError(lastErr) ? lastErr.message : String(lastErr)
}`,
);
}
export const BASE = BASE_URL;

67
e2e/fixtures/auth.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Playwright auth helpers: log in via the API once per role, hydrate
* localStorage so subsequent page.goto() lands inside the SPA already
* authenticated.
*
* Avoids the per-test cost of driving the LoginPage form just to land on
* /engagements. The actual UI login flow IS exercised in us2-login.spec.ts.
*/
import { type BrowserContext, type Page } from '@playwright/test';
import { BASE, login, type Role, type User } from './api';
export interface Session {
token: string;
user: User;
}
/**
* Inject token into localStorage so the SPA's bootstrap hook picks it up
* as if the user had already logged in. The frontend stores the JWT under
* `mimic.token` (see frontend/src/api/client.ts).
*/
export async function seedTokenInStorage(
context: BrowserContext,
token: string,
): Promise<void> {
await context.addInitScript((t) => {
try {
window.localStorage.setItem('mimic.token', t);
} catch {
/* storage might not exist on about:blank — harmless */
}
}, token);
}
export async function clearAuthStorage(context: BrowserContext): Promise<void> {
await context.addInitScript(() => {
try {
window.localStorage.removeItem('mimic.token');
} catch {
/* noop */
}
});
}
/**
* Log in as the given role and return both the API session and a helper
* that prepares a Page with the auth token already seeded.
*/
export async function loginAs(
context: BrowserContext,
username: string,
password: string,
): Promise<Session> {
const session = await login(username, password);
await seedTokenInStorage(context, session.token);
return session;
}
/**
* Convenience: navigate to a path on a page that's already had its
* context seeded with a token.
*/
export async function gotoApp(page: Page, path = '/engagements'): Promise<void> {
await page.goto(`${BASE}${path}`);
}
export type { Role };

470
e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,470 @@
{
"name": "mimic-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mimic-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.7.5",
"axios": "^1.7.7",
"typescript": "^5.6.2"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

17
e2e/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "mimic-e2e",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.7.5",
"axios": "^1.7.7",
"typescript": "^5.6.2"
}
}

40
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Single-container e2e setup — Mimic backend serves both /api/* and the built SPA.
*
* Sequence (run manually before `npx playwright test`):
* 1. make build && make start
* 2. make create-admin USER=root PASS=rootpass8
* 3. ensure `curl /api/health` is 200
*
* Tests run **serially** because all state lives in a single SQLite file in the
* shared container. RBAC tests need stable user fixtures across spec files.
*/
const baseURL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
export default defineConfig({
testDir: './tests',
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL,
headless: true,
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 15_000,
extraHTTPHeaders: { 'Content-Type': 'application/json' },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -0,0 +1,109 @@
/**
* US-1 — bootstrap the first admin via `flask create-admin`.
*
* The `make create-admin` target wraps `docker exec mimic flask create-admin …`.
* These tests exercise the CLI directly through `docker exec` (or whatever
* runtime the harness exposes via the wrapper), and a follow-up API login to
* confirm the row was created with role=admin and an argon2 hash that verifies.
*
* NOTE: the bootstrap admin (`root` / `rootpass8`) is already created out-of-band
* before the Playwright suite starts. Test usernames here are scoped to `us1-*`
* and cleaned up via API in afterAll.
*/
import { test, expect } from '@playwright/test';
import { execSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { adminToken, deleteUserByUsername, login, makeClient } from '../fixtures/api';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
function runCreateAdmin(user: string, pass: string): {
stdout: string;
stderr: string;
status: number;
} {
try {
const stdout = execSync(`${RUNTIME} exec ${CONTAINER} flask create-admin ${user} ${pass}`, {
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
});
return { stdout, stderr: '', status: 0 };
} catch (e) {
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
return {
stdout: typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '',
stderr: typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '',
status: err.status ?? -1,
};
}
}
test.describe('US-1 — bootstrap first admin', () => {
const probeUser = 'us1-probe-admin';
const dupUser = 'us1-dup-admin';
test.afterAll(async () => {
try {
const token = await adminToken();
for (const u of [probeUser, dupUser]) {
await deleteUserByUsername(token, u);
}
} catch {
/* best-effort cleanup */
}
});
test('AC-1.1 — create-admin creates a user with role=admin and an argon2 hash that authenticates', async () => {
const probePass = 'probepass123';
const result = runCreateAdmin(probeUser, probePass);
expect(result.status, `CLI failed: ${result.stderr || result.stdout}`).toBe(0);
expect(result.stdout).toMatch(/created/i);
// Roundtrip: the resulting credentials must log in as role=admin.
const { user } = await login(probeUser, probePass);
expect(user.username).toBe(probeUser);
expect(user.role).toBe('admin');
});
test('AC-1.2 — fails cleanly when username already exists', async () => {
// Seed once, then call again.
runCreateAdmin(dupUser, 'firstpass8');
const second = runCreateAdmin(dupUser, 'secondpass8');
expect(second.status).not.toBe(0);
const combined = (second.stderr + second.stdout).toLowerCase();
expect(combined).toMatch(/exists|already|error/);
});
test('AC-1.3 — refuses passwords shorter than 8 characters', async () => {
const result = runCreateAdmin('us1-shortpass-user', 'short7');
expect(result.status).not.toBe(0);
const combined = (result.stderr + result.stdout).toLowerCase();
expect(combined).toMatch(/8|length|password/);
// Make sure the short-password attempt did NOT create a row.
const probe = await makeClient().post('/auth/login', {
username: 'us1-shortpass-user',
password: 'short7',
});
expect(probe.status).toBe(401);
});
test('AC-1.4 — make create-admin wraps `docker exec mimic flask create-admin` (the bootstrap admin proves it ran via that path)', async () => {
// The harness step `make create-admin USER=root PASS=rootpass8` is what
// got the suite to the point where adminToken() works. If that call had
// been broken, this assertion would have already failed when seeding.
const token = await adminToken();
expect(token).toBeTruthy();
// Defence-in-depth: assert the Makefile target literally invokes
// `docker exec … flask create-admin`. This is a contract check.
const makefilePath = resolve(__dirname, '../..', 'Makefile');
const content = readFileSync(makefilePath, 'utf8');
expect(content).toMatch(/docker exec .+ flask create-admin/);
});
});

158
e2e/tests/us2-login.spec.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* US-2 — login / logout / session expiry.
*
* Covers AC-2.1 through AC-2.6. Mix of pure-API assertions (token shape +
* generic 401) and full UI flow (form submit, redirect, expired-token toast).
*/
import { test, expect } from '@playwright/test';
import { adminToken, ensureUser, deleteUserByUsername, makeClient } from '../fixtures/api';
import { clearAuthStorage, seedTokenInStorage } from '../fixtures/auth';
const USERNAME = 'us2-loginuser';
const PASSWORD = 'us2-pass8chars';
test.describe('US-2 — auth flow', () => {
test.beforeAll(async () => {
await ensureUser(USERNAME, PASSWORD, 'redteam');
});
test.afterAll(async () => {
try {
const token = await adminToken();
await deleteUserByUsername(token, USERNAME);
} catch {
/* noop */
}
});
test('AC-2.1 — POST /api/auth/login returns access_token + user payload', async () => {
const client = makeClient();
const r = await client.post('/auth/login', {
username: USERNAME,
password: PASSWORD,
});
expect(r.status).toBe(200);
expect(typeof r.data.access_token).toBe('string');
expect(r.data.access_token.length).toBeGreaterThan(20);
expect(r.data.user).toMatchObject({
username: USERNAME,
role: 'redteam',
});
expect(typeof r.data.user.id).toBe('number');
});
test('AC-2.2 — bad credentials return 401 with generic error (no username/password leak)', async () => {
const client = makeClient();
const wrongPass = await client.post('/auth/login', {
username: USERNAME,
password: 'nope-not-the-password',
});
expect(wrongPass.status).toBe(401);
expect(wrongPass.data.error).toBe('Invalid credentials');
const noUser = await client.post('/auth/login', {
username: 'does-not-exist-anywhere',
password: 'whatever12345',
});
expect(noUser.status).toBe(401);
expect(noUser.data.error).toBe('Invalid credentials');
// Critical: same message in both cases — no enumeration of users.
expect(noUser.data.error).toBe(wrongPass.data.error);
});
test('AC-2.3 — logout: client-side token purge (UI clears localStorage)', async ({
page,
context,
}) => {
await page.goto('/login');
await page.fill('input[name="username"]', USERNAME);
await page.fill('input[name="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(/\/engagements\b/);
// Token must now exist in localStorage.
const before = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
expect(before).toBeTruthy();
// Click "Sign out" (Layout topbar).
await page.getByRole('button', { name: /sign out/i }).click();
await page.waitForURL(/\/login\b/);
const after = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
expect(after).toBeNull();
});
test('AC-2.4 — /login form: success → /engagements, failure → visible error message', async ({
page,
}) => {
await page.goto('/login');
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
// Failure path first.
await page.fill('input[name="username"]', USERNAME);
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
const errorLocator = page.getByTestId('login-error');
await expect(errorLocator).toBeVisible();
await expect(errorLocator).toHaveText(/invalid credentials/i);
// Now success.
await page.fill('input[name="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(/\/engagements\b/);
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
});
test('AC-2.5 — navigating to /engagements without a token redirects to /login', async ({
page,
context,
}) => {
await clearAuthStorage(context);
await context.clearCookies();
await page.goto('/engagements');
await page.waitForURL(/\/login\b/);
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
});
test('AC-2.6 — 401 from API purges token + redirects to /login with "Session expirée" toast', async ({
page,
context,
}) => {
// Seed a clearly-invalid token so the very first API call (from /me on
// app bootstrap) returns 401, tripping the axios interceptor.
await seedTokenInStorage(context, 'totally.invalid.jwt');
// Race-safe toast capture: poll the DOM while React renders + the
// interceptor's window.location.assign('/login') tears the page down.
let sawToast = false;
const stopWatching = page
.waitForSelector('[data-testid="toast"]:has-text("Session expir")', {
state: 'visible',
timeout: 8_000,
})
.then(() => {
sawToast = true;
})
.catch(() => {
/* didn't catch the toast in time — assertion below will flag */
});
await page.goto('/engagements');
// Interceptor should boot us to /login.
await page.waitForURL(/\/login\b/, { timeout: 10_000 });
// Storage must be purged. Use waitForFunction to dodge the navigation race.
await page.waitForFunction(() => window.localStorage.getItem('mimic.token') === null, {
timeout: 5_000,
});
await stopWatching;
expect(
sawToast,
'expected "Session expirée" toast to be visible at some point during the 401 redirect',
).toBe(true);
});
});

View File

@@ -0,0 +1,235 @@
/**
* US-3 — admin manages user accounts.
* Covers AC-3.1 → AC-3.7. RBAC matrix exercised at the API for each verb,
* and the /admin/users page is exercised in the UI for create + role-gate.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
deleteUserByUsername,
ensureUser,
login,
makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us3-redteam';
const SOC_USER = 'us3-soc';
const PASS = 'us3-pass-strong';
test.describe('US-3 — user admin', () => {
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
});
test.afterAll(async () => {
try {
const token = await adminToken();
for (const u of [
REDTEAM_USER,
SOC_USER,
'us3-created-via-api',
'us3-patched',
'us3-deleted',
'us3-ui-newuser',
]) {
await deleteUserByUsername(token, u);
}
} catch {
/* best-effort */
}
});
test('AC-3.1 — GET /api/users returns list for admin', async () => {
const token = await adminToken();
const client = makeClient(token);
const r = await client.get('/users');
expect(r.status).toBe(200);
expect(Array.isArray(r.data)).toBe(true);
const sample = (r.data as Array<Record<string, unknown>>).find(
(u) => u.username === REDTEAM_USER,
);
expect(sample).toBeTruthy();
expect(sample).toMatchObject({ role: 'redteam' });
expect(sample!.password_hash).toBeUndefined();
expect(sample!.id).toBeDefined();
expect(sample!.created_at).toBeDefined();
});
test('AC-3.2 — POST /api/users creates user (201); 400 on duplicate or short password', async () => {
const token = await adminToken();
const client = makeClient(token);
const created = await client.post('/users', {
username: 'us3-created-via-api',
password: 'longenough8',
role: 'soc',
});
expect(created.status).toBe(201);
expect(created.data).toMatchObject({
username: 'us3-created-via-api',
role: 'soc',
});
expect(created.data.password_hash).toBeUndefined();
const dup = await client.post('/users', {
username: 'us3-created-via-api',
password: 'longenough8',
role: 'soc',
});
expect(dup.status).toBe(400);
const short = await client.post('/users', {
username: 'us3-shortpw-x',
password: 'short7',
role: 'soc',
});
expect(short.status).toBe(400);
});
test('AC-3.3 — PATCH /api/users/<id> updates role and/or password', async () => {
const token = await adminToken();
const client = makeClient(token);
// Seed a fresh user.
const created = await client.post('/users', {
username: 'us3-patched',
password: 'initialpass8',
role: 'soc',
});
expect(created.status).toBe(201);
const id = created.data.id as number;
// PATCH role.
const r1 = await client.patch(`/users/${id}`, { role: 'redteam' });
expect(r1.status).toBe(200);
expect(r1.data.role).toBe('redteam');
// PATCH password — must be usable for login.
const r2 = await client.patch(`/users/${id}`, { password: 'newstrongpass8' });
expect(r2.status).toBe(200);
const { token: rotated } = await login('us3-patched', 'newstrongpass8');
expect(rotated).toBeTruthy();
});
test('AC-3.4 — DELETE /api/users/<id> returns 204; refuses last admin (409)', async () => {
const token = await adminToken();
const client = makeClient(token);
// Create a disposable redteam, delete it → 204.
const created = await client.post('/users', {
username: 'us3-deleted',
password: 'disposable8',
role: 'redteam',
});
expect(created.status).toBe(201);
const id = created.data.id as number;
const del = await client.delete(`/users/${id}`);
expect(del.status).toBe(204);
// Verify gone.
const list = await client.get('/users');
const found = (list.data as Array<{ id: number }>).find((u) => u.id === id);
expect(found).toBeUndefined();
// Last-admin protection — list admins and try to delete the only one.
const all = await client.get('/users');
const admins = (all.data as Array<{ id: number; role: string }>).filter(
(u) => u.role === 'admin',
);
if (admins.length === 1) {
const r = await client.delete(`/users/${admins[0].id}`);
expect(r.status).toBe(409);
} else {
// If suite added extra admins, demote-then-delete protection still applies:
// we attempt a hypothetical demote of one admin (PATCH to redteam) and the
// last one must be refused.
// Iterate: keep deleting admins one by one until 1 remains, then assert 409.
// (Skipped in well-isolated runs because typical state = 1 admin.)
const ids = admins.map((a) => a.id);
while (ids.length > 1) {
const victim = ids.pop()!;
const r = await client.delete(`/users/${victim}`);
expect(r.status).toBe(204);
}
const finalId = ids[0];
const r = await client.delete(`/users/${finalId}`);
expect(r.status).toBe(409);
}
});
test('AC-3.5 — redteam and soc receive 403 on user-admin endpoints', async () => {
for (const role of ['redteam', 'soc'] as const) {
const username = role === 'redteam' ? REDTEAM_USER : SOC_USER;
const { token } = await login(username, PASS);
const client = makeClient(token);
const list = await client.get('/users');
expect(list.status, `${role} GET /users`).toBe(403);
const post = await client.post('/users', {
username: `${role}-attempt`,
password: 'whatever8x',
role: 'soc',
});
expect(post.status, `${role} POST /users`).toBe(403);
const patch = await client.patch('/users/1', { role: 'soc' });
expect(patch.status, `${role} PATCH /users/1`).toBe(403);
const del = await client.delete('/users/999');
expect(del.status, `${role} DELETE /users/999`).toBe(403);
}
});
test('AC-3.6 — /admin/users page lists users + allows create + reset-password + delete', async ({
page,
context,
}) => {
const token = await adminToken();
await seedTokenInStorage(context, token);
await page.goto('/admin/users');
await expect(page.getByRole('heading', { name: /user accounts/i })).toBeVisible();
// Create new user via the form.
const newName = 'us3-ui-newuser';
await page.fill('#new-username', newName);
await page.fill('#new-password', 'uistrongpw8');
await page.selectOption('#new-role', 'soc');
await page.getByRole('button', { name: /^create$/i }).click();
// Row appears.
const row = page.getByRole('row', { name: new RegExp(newName) });
await expect(row).toBeVisible();
// Reset password flow opens a sub-form.
await row.getByRole('button', { name: /reset password/i }).click();
await page.fill(`input[id^="reset-"]`, 'rotatedpass8');
await page.getByRole('button', { name: /save password/i }).click();
await expect(page.getByTestId('toast').filter({ hasText: /password reset/i })).toBeVisible();
// Delete row (confirm dialog).
page.once('dialog', (d) => d.accept());
await row.getByRole('button', { name: /^delete$/i }).click();
await expect(page.getByRole('row', { name: new RegExp(newName) })).toHaveCount(0, {
timeout: 5_000,
});
});
test('AC-3.7 — redteam/soc visiting /admin/users → redirected to /engagements + "Accès refusé" toast', async ({
page,
context,
}) => {
const { token } = await login(SOC_USER, PASS);
await seedTokenInStorage(context, token);
await page.goto('/admin/users');
await page.waitForURL(/\/engagements\b/, { timeout: 5_000 });
await expect(
page.getByTestId('toast').filter({ hasText: /accès refusé/i }),
).toBeVisible();
});
});

View File

@@ -0,0 +1,270 @@
/**
* US-4 — engagement CRUD + RBAC + UI surfaces.
* Covers AC-4.1 → AC-4.9.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteAllEngagements,
deleteEngagement,
deleteUserByUsername,
ensureUser,
listEngagements,
login,
makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us4-redteam';
const SOC_USER = 'us4-soc';
const PASS = 'us4-pass-strong';
test.describe('US-4 — engagement CRUD', () => {
let redteamToken: string;
let socToken: string;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
// Clean slate so AC-4.7 list assertions are predictable.
await deleteAllEngagements(await adminToken());
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteAllEngagements(tok);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-4.1 — GET /api/engagements returns serialized list (created_by = {id, username})', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.1 sample',
start_date: '2026-02-01',
});
const items = await listEngagements(redteamToken);
const row = items.find((i) => i.id === seeded.id);
expect(row).toBeTruthy();
expect(row).toMatchObject({
name: 'AC-4.1 sample',
status: 'planned',
start_date: '2026-02-01',
});
expect(row!.created_by).toMatchObject({ username: REDTEAM_USER });
expect(typeof row!.created_by!.id).toBe('number');
});
test('AC-4.2 — POST validates name/dates/status', async () => {
const client = makeClient(redteamToken);
const blankName = await client.post('/engagements', {
name: '',
start_date: '2026-03-01',
});
expect(blankName.status).toBe(400);
const noStart = await client.post('/engagements', { name: 'x' });
expect(noStart.status).toBe(400);
const badDate = await client.post('/engagements', {
name: 'x',
start_date: 'not-a-date',
});
expect(badDate.status).toBe(400);
const endBeforeStart = await client.post('/engagements', {
name: 'x',
start_date: '2026-04-10',
end_date: '2026-04-01',
});
expect(endBeforeStart.status).toBe(400);
const badStatus = await client.post('/engagements', {
name: 'x',
start_date: '2026-04-01',
status: 'frozen',
});
expect(badStatus.status).toBe(400);
const defaultStatus = await client.post('/engagements', {
name: 'AC-4.2 default-status',
start_date: '2026-04-01',
});
expect(defaultStatus.status).toBe(201);
expect(defaultStatus.data.status).toBe('planned');
});
test('AC-4.3 — GET /api/engagements/<id> returns 200 + object, 404 if unknown', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.3 sample',
start_date: '2026-05-01',
});
const client = makeClient(redteamToken);
const ok = await client.get(`/engagements/${seeded.id}`);
expect(ok.status).toBe(200);
expect(ok.data.id).toBe(seeded.id);
const missing = await client.get('/engagements/999999');
expect(missing.status).toBe(404);
});
test('AC-4.4 — PATCH (admin/redteam) updates fields', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.4 orig',
start_date: '2026-06-01',
});
const client = makeClient(redteamToken);
const r = await client.patch(`/engagements/${seeded.id}`, {
name: 'AC-4.4 updated',
status: 'active',
end_date: '2026-06-15',
});
expect(r.status).toBe(200);
expect(r.data).toMatchObject({
name: 'AC-4.4 updated',
status: 'active',
end_date: '2026-06-15',
});
});
test('AC-4.5 — DELETE (admin/redteam) returns 204', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.5 disposable',
start_date: '2026-07-01',
});
const client = makeClient(redteamToken);
const r = await client.delete(`/engagements/${seeded.id}`);
expect(r.status).toBe(204);
const after = await client.get(`/engagements/${seeded.id}`);
expect(after.status).toBe(404);
});
test('AC-4.6 — soc can read but not write (403 on POST/PATCH/DELETE)', async () => {
const socClient = makeClient(socToken);
const list = await socClient.get('/engagements');
expect(list.status).toBe(200);
const post = await socClient.post('/engagements', {
name: 'soc-blocked',
start_date: '2026-08-01',
});
expect(post.status).toBe(403);
// Seed via redteam to get a target id.
const target = await createEngagement(redteamToken, {
name: 'AC-4.6 target',
start_date: '2026-08-15',
});
const patch = await socClient.patch(`/engagements/${target.id}`, { name: 'soc-edit' });
expect(patch.status).toBe(403);
const del = await socClient.delete(`/engagements/${target.id}`);
expect(del.status).toBe(403);
// Clean up via redteam.
await deleteEngagement(redteamToken, target.id);
});
test('AC-4.7 — /engagements page lists rows with required columns + role-aware buttons', async ({
page,
context,
}) => {
// Seed one row visible to the redteam user.
await createEngagement(redteamToken, {
name: 'UI list sample',
start_date: '2026-09-01',
status: 'active',
});
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
// Column headers
for (const h of ['Name', 'Status', 'Start', 'End', 'Created by']) {
await expect(page.getByRole('columnheader', { name: new RegExp(h, 'i') })).toBeVisible();
}
// The row + status badge + created_by visible
const row = page.getByRole('row', { name: /UI list sample/i });
await expect(row).toBeVisible();
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
// Redteam sees the action buttons.
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible();
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
// Soc should NOT see write buttons.
await seedTokenInStorage(context, socToken);
await page.goto('/engagements');
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
await expect(rowAsSoc).toBeVisible();
await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0);
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
});
test('AC-4.8 — /engagements/new form: client validation + API error display', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements/new');
await expect(page.getByRole('heading', { name: /new engagement/i })).toBeVisible();
// Submit empty → client-side errors visible.
await page.getByRole('button', { name: /create engagement/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page.getByText(/start date is required/i)).toBeVisible();
// Fill bad date order → client validation flags end_date.
await page.fill('#eng-name', 'UI form test');
await page.fill('#eng-start', '2026-10-10');
await page.fill('#eng-end', '2026-10-01');
await page.getByRole('button', { name: /create engagement/i }).click();
await expect(page.getByText(/end date must be on or after start date/i)).toBeVisible();
// Fix dates → submit succeeds, redirects to detail.
await page.fill('#eng-end', '2026-10-20');
await page.getByRole('button', { name: /create engagement/i }).click();
await page.waitForURL(/\/engagements\/\d+$/);
await expect(page.getByRole('heading', { name: /UI form test/i })).toBeVisible();
// Edit path: navigate to /edit and tweak.
const detailUrl = page.url();
const id = Number(detailUrl.split('/').pop());
await page.goto(`/engagements/${id}/edit`);
await expect(page.getByRole('heading', { name: /edit engagement/i })).toBeVisible();
await page.fill('#eng-name', 'UI form test (edited)');
await page.getByRole('button', { name: /save changes/i }).click();
await page.waitForURL(new RegExp(`/engagements/${id}$`));
await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible();
});
test('AC-4.9 — /engagements/<id> detail page shows Sprint 2 placeholder', async ({
page,
context,
}) => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.9 detail target',
start_date: '2026-11-01',
description: 'A description for detail rendering.',
});
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${seeded.id}`);
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
await expect(
page.getByText(/simulations à venir au sprint 2/i),
).toBeVisible();
});
});

View File

@@ -0,0 +1,161 @@
/**
* US-5 — UI respects DESIGN.md tokens, layout doesn't break at 1280×720,
* and loading/error/empty states are wired.
*
* Pixel-perfect fidelity is NOT enforced — instead we assert that the
* tokenised classes from tailwind.config.ts are present and that the canvas
* has no horizontal overflow at the design viewport.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
deleteAllEngagements,
deleteUserByUsername,
ensureUser,
login,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us5-redteam';
const PASS = 'us5-passw0rd';
test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
let redteamToken: string;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteAllEngagements(tok);
await deleteUserByUsername(tok, REDTEAM_USER);
} catch {
/* noop */
}
});
test('AC-5.1 — DESIGN.md tokens applied (Inter font, brand palette, named utilities)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
// Body font must resolve to a stack including "Inter".
const fontFamily = await page.evaluate(
() => window.getComputedStyle(document.body).fontFamily,
);
expect(fontFamily.toLowerCase()).toMatch(/inter/);
// Tailwind-compiled DESIGN palette: the primary chevron at the top-left of
// the nav uses `bg-primary` → rendered as rgb(2, 74, 216).
const chevronBg = await page
.locator('header a[aria-label="Mimic home"] span[aria-hidden]')
.first()
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)');
// Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)).
const utilityBg = await page
.locator('div.bg-ink.text-ink-on')
.first()
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)');
// Spot-check a few semantic class names live in the DOM (proves tokens are
// wired through tailwind.config.ts and not ad-hoc hex values).
await expect(page.locator('.btn-primary, .card-product, .max-w-page').first()).toBeVisible();
});
test('AC-5.2 — no horizontal overflow at 1280×720 across key pages', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.setViewportSize({ width: 1280, height: 720 });
const routes = ['/engagements', '/engagements/new'];
for (const route of routes) {
await page.goto(route);
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(
overflow.scrollWidth,
`${route} should not horizontally overflow at 1280px`,
).toBeLessThanOrEqual(overflow.clientWidth + 1);
}
});
test('AC-5.3a — empty state renders when engagements list is empty', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
const tok = await adminToken();
await deleteAllEngagements(tok);
await page.goto('/engagements');
await expect(page.getByRole('heading', { name: /no engagements yet/i })).toBeVisible();
});
test('AC-5.3b — loading state renders while engagements list is in-flight', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
// Stall the list endpoint so the LoadingState becomes visible.
const handler = async (route: import('@playwright/test').Route) => {
const url = route.request().url();
if (/\/api\/engagements(\?.*)?$/.test(url)) {
await new Promise((r) => setTimeout(r, 1500));
}
try {
await route.continue();
} catch {
/* route may have been torn down by the time we resume */
}
};
await page.route('**/api/engagements**', handler);
const navPromise = page.goto('/engagements');
await expect(page.getByText(/loading engagements/i)).toBeVisible({ timeout: 3_000 });
await navPromise;
await page.unroute('**/api/engagements**', handler);
});
test('AC-5.3c — error state renders when the engagements list returns 500', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
const handler = async (route: import('@playwright/test').Route) => {
const url = route.request().url();
if (/\/api\/engagements(\?.*)?$/.test(url)) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Forced failure' }),
});
return;
}
try {
await route.continue();
} catch {
/* route teardown race — harmless */
}
};
await page.route('**/api/engagements**', handler);
await page.goto('/engagements');
await expect(page.getByTestId('error-state')).toBeVisible({ timeout: 8_000 });
await expect(page.getByTestId('error-state')).toContainText(/forced failure/i);
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
await page.unroute('**/api/engagements**', handler);
});
});

View File

@@ -0,0 +1,117 @@
/**
* US-6 — deployment via Makefile + Docker.
*
* These checks are infrastructure-level: they shell out to `make` and the
* container runtime to assert the build/run targets behave correctly and
* the SQLite volume survives a restart.
*
* The container is expected to already be `up` when the suite starts (the
* harness runs `make build && make start && make create-admin` before
* Playwright). So `AC-6.1`/`AC-6.2` are verified via image presence + HTTP
* smoke; `AC-6.3` exercises stop/restart/logs; `AC-6.4` writes a row, restarts,
* and re-reads it; `AC-6.5` confirms the test targets exist as Makefile rules.
*/
import { test, expect } from '@playwright/test';
import { execSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import {
adminToken,
createEngagement,
deleteAllEngagements,
listEngagements,
waitForHealth,
} from '../fixtures/api';
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
const IMAGE = process.env.MIMIC_IMAGE ?? 'mimic:latest';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '../..');
function run(cmd: string, opts: { ignoreFail?: boolean } = {}): { status: number; out: string } {
try {
const out = execSync(cmd, {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
});
return { status: 0, out };
} catch (e) {
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
const out =
(typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '') +
(typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '');
if (opts.ignoreFail) return { status: err.status ?? -1, out };
throw new Error(`command failed (${err.status}): ${cmd}\n${out}`);
}
}
test.describe('US-6 — deployment via Docker + Makefile', () => {
test('AC-6.1 — image mimic:latest exists (built by make build)', () => {
const r = run(`${RUNTIME} images --format "{{.Repository}}:{{.Tag}}"`);
expect(r.out).toMatch(new RegExp(IMAGE.replace(/[.:]/g, '\\$&')));
});
test('AC-6.2 — container responds on http://localhost:5000 (front + /api/health)', async () => {
await waitForHealth(5_000);
// Frontend is served at "/". Use 127.0.0.1 explicitly so envs where
// `localhost` resolves to ::1 (where the container port isn't bound)
// don't break this contract test.
const base = (process.env.MIMIC_BASE_URL ?? 'http://127.0.0.1:5000').replace(/\/$/, '');
const html = run(`curl -fsS ${base}/`).out;
expect(html).toMatch(/<!doctype html>/i);
expect(html.toLowerCase()).toMatch(/mimic/);
});
test('AC-6.3 — make stop / make restart / make logs are well-formed targets', () => {
// Don't actually stop the container mid-suite — that would tear down
// the other tests. Instead, verify the Makefile rules exist and are
// syntactically valid by asking make for their recipes via `--just-print`.
// `make restart` expands to `make stop && make start`, so we look for
// those sub-commands instead of a literal "restart" token.
const dry = run('make --dry-run --no-print-directory stop restart logs');
expect(dry.out).toMatch(new RegExp(`${RUNTIME} stop ${CONTAINER}`));
expect(dry.out).toMatch(/make stop && make start/);
expect(dry.out).toMatch(new RegExp(`${RUNTIME} logs -f ${CONTAINER}`));
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
expect(makefile).toMatch(/^stop:/m);
expect(makefile).toMatch(/^restart:/m);
expect(makefile).toMatch(/^logs:/m);
});
test('AC-6.4 — SQLite persists across container restart (named volume mimic-data)', async () => {
const token = await adminToken();
// Seed a unique engagement.
const marker = `AC-6.4-persistence-${Date.now()}`;
await createEngagement(token, { name: marker, start_date: '2026-12-01' });
// Restart the container (NOT make restart, since that would also rm —
// we do `runtime restart` which keeps the same container + volume).
run(`${RUNTIME} restart ${CONTAINER}`);
await waitForHealth(20_000);
const token2 = await adminToken();
const items = await listEngagements(token2);
const found = items.find((i) => i.name === marker);
expect(found, `engagement seeded before restart should survive`).toBeTruthy();
// Cleanup.
await deleteAllEngagements(token2);
});
test('AC-6.5 — make test-backend / test-frontend / test-e2e are defined', () => {
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
expect(makefile).toMatch(/^test-backend:/m);
expect(makefile).toMatch(/^test-frontend:/m);
expect(makefile).toMatch(/^test-e2e:/m);
// Dry-run them to make sure the recipes are syntactically valid.
const dry = run('make --dry-run --no-print-directory test-backend test-frontend test-e2e');
expect(dry.out).toMatch(/pytest/);
expect(dry.out).toMatch(/npm run test/);
expect(dry.out).toMatch(/playwright test/);
});
});

18
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "@playwright/test"],
"baseUrl": ".",
"paths": {
"@fixtures/*": ["fixtures/*"],
"@helpers/*": ["helpers/*"]
}
},
"include": ["**/*.ts"]
}