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

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