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:
171
e2e/fixtures/api.ts
Normal file
171
e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Thin axios client used by tests to seed/teardown users and engagements
|
||||
* without going through the UI. The bootstrap admin is created out-of-band
|
||||
* (via `make create-admin`) and logs in once to provision per-suite users.
|
||||
*/
|
||||
import axios, { AxiosInstance, isAxiosError } from 'axios';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'redteam' | 'soc';
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
status: 'planned' | 'active' | 'closed';
|
||||
created_at: string | null;
|
||||
created_by: { id: number; username: string } | null;
|
||||
}
|
||||
|
||||
export type Role = User['role'];
|
||||
|
||||
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||
const BOOTSTRAP_ADMIN_USER = process.env.MIMIC_BOOTSTRAP_USER ?? 'root';
|
||||
const BOOTSTRAP_ADMIN_PASS = process.env.MIMIC_BOOTSTRAP_PASS ?? 'rootpass8';
|
||||
|
||||
export function makeClient(token?: string): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: `${BASE_URL}/api`,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
validateStatus: () => true, // tests assert on status themselves
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const client = makeClient();
|
||||
const r = await client.post('/auth/login', { username, password });
|
||||
if (r.status !== 200) {
|
||||
throw new Error(`login(${username}) failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return { token: r.data.access_token as string, user: r.data.user as User };
|
||||
}
|
||||
|
||||
export async function adminToken(): Promise<string> {
|
||||
const { token } = await login(BOOTSTRAP_ADMIN_USER, BOOTSTRAP_ADMIN_PASS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent helper: ensures a user with the given username/role exists and
|
||||
* has the requested password. Returns the user record.
|
||||
*
|
||||
* Strategy:
|
||||
* - try login: if it succeeds, we're done.
|
||||
* - else: as admin, list users; if username found, PATCH password+role; else POST.
|
||||
*/
|
||||
export async function ensureUser(
|
||||
username: string,
|
||||
password: string,
|
||||
role: Role,
|
||||
): Promise<User> {
|
||||
try {
|
||||
const { user } = await login(username, password);
|
||||
if (user.role !== role) {
|
||||
const admin = await adminToken();
|
||||
const client = makeClient(admin);
|
||||
const r = await client.patch(`/users/${user.id}`, { role });
|
||||
if (r.status !== 200) throw new Error(`patch role: ${r.status}`);
|
||||
return r.data as User;
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
// fall through to admin path
|
||||
}
|
||||
const admin = await adminToken();
|
||||
const client = makeClient(admin);
|
||||
const list = await client.get('/users');
|
||||
if (list.status !== 200) {
|
||||
throw new Error(`list users failed: ${list.status} ${JSON.stringify(list.data)}`);
|
||||
}
|
||||
const existing = (list.data as User[]).find((u) => u.username === username);
|
||||
if (existing) {
|
||||
const r = await client.patch(`/users/${existing.id}`, { password, role });
|
||||
if (r.status !== 200) {
|
||||
throw new Error(`patch user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as User;
|
||||
}
|
||||
const r = await client.post('/users', { username, password, role });
|
||||
if (r.status !== 201) {
|
||||
throw new Error(`create user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as User;
|
||||
}
|
||||
|
||||
export async function deleteUserByUsername(token: string, username: string): Promise<void> {
|
||||
const client = makeClient(token);
|
||||
const list = await client.get('/users');
|
||||
if (list.status !== 200) return;
|
||||
const u = (list.data as User[]).find((x) => x.username === username);
|
||||
if (!u) return;
|
||||
await client.delete(`/users/${u.id}`);
|
||||
}
|
||||
|
||||
export async function createEngagement(
|
||||
token: string,
|
||||
payload: Partial<Pick<Engagement, 'name' | 'description' | 'start_date' | 'end_date' | 'status'>>,
|
||||
): Promise<Engagement> {
|
||||
const client = makeClient(token);
|
||||
const body = {
|
||||
name: payload.name ?? 'Test Engagement',
|
||||
description: payload.description,
|
||||
start_date: payload.start_date ?? '2026-01-01',
|
||||
end_date: payload.end_date,
|
||||
status: payload.status ?? 'planned',
|
||||
};
|
||||
const r = await client.post('/engagements', body);
|
||||
if (r.status !== 201) {
|
||||
throw new Error(`create engagement failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as Engagement;
|
||||
}
|
||||
|
||||
export async function deleteEngagement(token: string, id: number): Promise<void> {
|
||||
const client = makeClient(token);
|
||||
await client.delete(`/engagements/${id}`);
|
||||
}
|
||||
|
||||
export async function listEngagements(token: string): Promise<Engagement[]> {
|
||||
const client = makeClient(token);
|
||||
const r = await client.get('/engagements');
|
||||
if (r.status !== 200) {
|
||||
throw new Error(`list engagements failed: ${r.status}`);
|
||||
}
|
||||
return r.data as Engagement[];
|
||||
}
|
||||
|
||||
export async function deleteAllEngagements(token: string): Promise<void> {
|
||||
const items = await listEngagements(token);
|
||||
await Promise.all(items.map((e) => deleteEngagement(token, e.id)));
|
||||
}
|
||||
|
||||
export async function waitForHealth(timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const client = makeClient();
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await client.get('/health');
|
||||
if (r.status === 200) return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`backend not healthy after ${timeoutMs}ms: ${
|
||||
isAxiosError(lastErr) ? lastErr.message : String(lastErr)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const BASE = BASE_URL;
|
||||
67
e2e/fixtures/auth.ts
Normal file
67
e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Playwright auth helpers: log in via the API once per role, hydrate
|
||||
* localStorage so subsequent page.goto() lands inside the SPA already
|
||||
* authenticated.
|
||||
*
|
||||
* Avoids the per-test cost of driving the LoginPage form just to land on
|
||||
* /engagements. The actual UI login flow IS exercised in us2-login.spec.ts.
|
||||
*/
|
||||
import { type BrowserContext, type Page } from '@playwright/test';
|
||||
import { BASE, login, type Role, type User } from './api';
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject token into localStorage so the SPA's bootstrap hook picks it up
|
||||
* as if the user had already logged in. The frontend stores the JWT under
|
||||
* `mimic.token` (see frontend/src/api/client.ts).
|
||||
*/
|
||||
export async function seedTokenInStorage(
|
||||
context: BrowserContext,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await context.addInitScript((t) => {
|
||||
try {
|
||||
window.localStorage.setItem('mimic.token', t);
|
||||
} catch {
|
||||
/* storage might not exist on about:blank — harmless */
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function clearAuthStorage(context: BrowserContext): Promise<void> {
|
||||
await context.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('mimic.token');
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the given role and return both the API session and a helper
|
||||
* that prepares a Page with the auth token already seeded.
|
||||
*/
|
||||
export async function loginAs(
|
||||
context: BrowserContext,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Session> {
|
||||
const session = await login(username, password);
|
||||
await seedTokenInStorage(context, session.token);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: navigate to a path on a page that's already had its
|
||||
* context seeded with a token.
|
||||
*/
|
||||
export async function gotoApp(page: Page, path = '/engagements'): Promise<void> {
|
||||
await page.goto(`${BASE}${path}`);
|
||||
}
|
||||
|
||||
export type { Role };
|
||||
470
e2e/package-lock.json
generated
Normal file
470
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"name": "mimic-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mimic-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^22.7.5",
|
||||
"axios": "^1.7.7",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
e2e/package.json
Normal file
17
e2e/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "mimic-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^22.7.5",
|
||||
"axios": "^1.7.7",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
40
e2e/playwright.config.ts
Normal file
40
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Single-container e2e setup — Mimic backend serves both /api/* and the built SPA.
|
||||
*
|
||||
* Sequence (run manually before `npx playwright test`):
|
||||
* 1. make build && make start
|
||||
* 2. make create-admin USER=root PASS=rootpass8
|
||||
* 3. ensure `curl /api/health` is 200
|
||||
*
|
||||
* Tests run **serially** because all state lives in a single SQLite file in the
|
||||
* shared container. RBAC tests need stable user fixtures across spec files.
|
||||
*/
|
||||
const baseURL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
use: {
|
||||
baseURL,
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
extraHTTPHeaders: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
109
e2e/tests/us1-bootstrap-admin.spec.ts
Normal file
109
e2e/tests/us1-bootstrap-admin.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* US-1 — bootstrap the first admin via `flask create-admin`.
|
||||
*
|
||||
* The `make create-admin` target wraps `docker exec mimic flask create-admin …`.
|
||||
* These tests exercise the CLI directly through `docker exec` (or whatever
|
||||
* runtime the harness exposes via the wrapper), and a follow-up API login to
|
||||
* confirm the row was created with role=admin and an argon2 hash that verifies.
|
||||
*
|
||||
* NOTE: the bootstrap admin (`root` / `rootpass8`) is already created out-of-band
|
||||
* before the Playwright suite starts. Test usernames here are scoped to `us1-*`
|
||||
* and cleaned up via API in afterAll.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { adminToken, deleteUserByUsername, login, makeClient } from '../fixtures/api';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
|
||||
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
|
||||
|
||||
function runCreateAdmin(user: string, pass: string): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
} {
|
||||
try {
|
||||
const stdout = execSync(`${RUNTIME} exec ${CONTAINER} flask create-admin ${user} ${pass}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return { stdout, stderr: '', status: 0 };
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
return {
|
||||
stdout: typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '',
|
||||
stderr: typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '',
|
||||
status: err.status ?? -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US-1 — bootstrap first admin', () => {
|
||||
const probeUser = 'us1-probe-admin';
|
||||
const dupUser = 'us1-dup-admin';
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const token = await adminToken();
|
||||
for (const u of [probeUser, dupUser]) {
|
||||
await deleteUserByUsername(token, u);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-1.1 — create-admin creates a user with role=admin and an argon2 hash that authenticates', async () => {
|
||||
const probePass = 'probepass123';
|
||||
const result = runCreateAdmin(probeUser, probePass);
|
||||
expect(result.status, `CLI failed: ${result.stderr || result.stdout}`).toBe(0);
|
||||
expect(result.stdout).toMatch(/created/i);
|
||||
|
||||
// Roundtrip: the resulting credentials must log in as role=admin.
|
||||
const { user } = await login(probeUser, probePass);
|
||||
expect(user.username).toBe(probeUser);
|
||||
expect(user.role).toBe('admin');
|
||||
});
|
||||
|
||||
test('AC-1.2 — fails cleanly when username already exists', async () => {
|
||||
// Seed once, then call again.
|
||||
runCreateAdmin(dupUser, 'firstpass8');
|
||||
const second = runCreateAdmin(dupUser, 'secondpass8');
|
||||
expect(second.status).not.toBe(0);
|
||||
const combined = (second.stderr + second.stdout).toLowerCase();
|
||||
expect(combined).toMatch(/exists|already|error/);
|
||||
});
|
||||
|
||||
test('AC-1.3 — refuses passwords shorter than 8 characters', async () => {
|
||||
const result = runCreateAdmin('us1-shortpass-user', 'short7');
|
||||
expect(result.status).not.toBe(0);
|
||||
const combined = (result.stderr + result.stdout).toLowerCase();
|
||||
expect(combined).toMatch(/8|length|password/);
|
||||
|
||||
// Make sure the short-password attempt did NOT create a row.
|
||||
const probe = await makeClient().post('/auth/login', {
|
||||
username: 'us1-shortpass-user',
|
||||
password: 'short7',
|
||||
});
|
||||
expect(probe.status).toBe(401);
|
||||
});
|
||||
|
||||
test('AC-1.4 — make create-admin wraps `docker exec mimic flask create-admin` (the bootstrap admin proves it ran via that path)', async () => {
|
||||
// The harness step `make create-admin USER=root PASS=rootpass8` is what
|
||||
// got the suite to the point where adminToken() works. If that call had
|
||||
// been broken, this assertion would have already failed when seeding.
|
||||
const token = await adminToken();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
// Defence-in-depth: assert the Makefile target literally invokes
|
||||
// `docker exec … flask create-admin`. This is a contract check.
|
||||
const makefilePath = resolve(__dirname, '../..', 'Makefile');
|
||||
const content = readFileSync(makefilePath, 'utf8');
|
||||
expect(content).toMatch(/docker exec .+ flask create-admin/);
|
||||
});
|
||||
});
|
||||
158
e2e/tests/us2-login.spec.ts
Normal file
158
e2e/tests/us2-login.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* US-2 — login / logout / session expiry.
|
||||
*
|
||||
* Covers AC-2.1 through AC-2.6. Mix of pure-API assertions (token shape +
|
||||
* generic 401) and full UI flow (form submit, redirect, expired-token toast).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { adminToken, ensureUser, deleteUserByUsername, makeClient } from '../fixtures/api';
|
||||
import { clearAuthStorage, seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const USERNAME = 'us2-loginuser';
|
||||
const PASSWORD = 'us2-pass8chars';
|
||||
|
||||
test.describe('US-2 — auth flow', () => {
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(USERNAME, PASSWORD, 'redteam');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const token = await adminToken();
|
||||
await deleteUserByUsername(token, USERNAME);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-2.1 — POST /api/auth/login returns access_token + user payload', async () => {
|
||||
const client = makeClient();
|
||||
const r = await client.post('/auth/login', {
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(typeof r.data.access_token).toBe('string');
|
||||
expect(r.data.access_token.length).toBeGreaterThan(20);
|
||||
expect(r.data.user).toMatchObject({
|
||||
username: USERNAME,
|
||||
role: 'redteam',
|
||||
});
|
||||
expect(typeof r.data.user.id).toBe('number');
|
||||
});
|
||||
|
||||
test('AC-2.2 — bad credentials return 401 with generic error (no username/password leak)', async () => {
|
||||
const client = makeClient();
|
||||
|
||||
const wrongPass = await client.post('/auth/login', {
|
||||
username: USERNAME,
|
||||
password: 'nope-not-the-password',
|
||||
});
|
||||
expect(wrongPass.status).toBe(401);
|
||||
expect(wrongPass.data.error).toBe('Invalid credentials');
|
||||
|
||||
const noUser = await client.post('/auth/login', {
|
||||
username: 'does-not-exist-anywhere',
|
||||
password: 'whatever12345',
|
||||
});
|
||||
expect(noUser.status).toBe(401);
|
||||
expect(noUser.data.error).toBe('Invalid credentials');
|
||||
|
||||
// Critical: same message in both cases — no enumeration of users.
|
||||
expect(noUser.data.error).toBe(wrongPass.data.error);
|
||||
});
|
||||
|
||||
test('AC-2.3 — logout: client-side token purge (UI clears localStorage)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="username"]', USERNAME);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/engagements\b/);
|
||||
|
||||
// Token must now exist in localStorage.
|
||||
const before = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
|
||||
expect(before).toBeTruthy();
|
||||
|
||||
// Click "Sign out" (Layout topbar).
|
||||
await page.getByRole('button', { name: /sign out/i }).click();
|
||||
await page.waitForURL(/\/login\b/);
|
||||
|
||||
const after = await page.evaluate(() => window.localStorage.getItem('mimic.token'));
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
test('AC-2.4 — /login form: success → /engagements, failure → visible error message', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
|
||||
|
||||
// Failure path first.
|
||||
await page.fill('input[name="username"]', USERNAME);
|
||||
await page.fill('input[name="password"]', 'wrongpassword');
|
||||
await page.click('button[type="submit"]');
|
||||
const errorLocator = page.getByTestId('login-error');
|
||||
await expect(errorLocator).toBeVisible();
|
||||
await expect(errorLocator).toHaveText(/invalid credentials/i);
|
||||
|
||||
// Now success.
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/engagements\b/);
|
||||
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-2.5 — navigating to /engagements without a token redirects to /login', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await clearAuthStorage(context);
|
||||
await context.clearCookies();
|
||||
await page.goto('/engagements');
|
||||
await page.waitForURL(/\/login\b/);
|
||||
await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-2.6 — 401 from API purges token + redirects to /login with "Session expirée" toast', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Seed a clearly-invalid token so the very first API call (from /me on
|
||||
// app bootstrap) returns 401, tripping the axios interceptor.
|
||||
await seedTokenInStorage(context, 'totally.invalid.jwt');
|
||||
|
||||
// Race-safe toast capture: poll the DOM while React renders + the
|
||||
// interceptor's window.location.assign('/login') tears the page down.
|
||||
let sawToast = false;
|
||||
const stopWatching = page
|
||||
.waitForSelector('[data-testid="toast"]:has-text("Session expir")', {
|
||||
state: 'visible',
|
||||
timeout: 8_000,
|
||||
})
|
||||
.then(() => {
|
||||
sawToast = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* didn't catch the toast in time — assertion below will flag */
|
||||
});
|
||||
|
||||
await page.goto('/engagements');
|
||||
|
||||
// Interceptor should boot us to /login.
|
||||
await page.waitForURL(/\/login\b/, { timeout: 10_000 });
|
||||
|
||||
// Storage must be purged. Use waitForFunction to dodge the navigation race.
|
||||
await page.waitForFunction(() => window.localStorage.getItem('mimic.token') === null, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await stopWatching;
|
||||
expect(
|
||||
sawToast,
|
||||
'expected "Session expirée" toast to be visible at some point during the 401 redirect',
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
235
e2e/tests/us3-users-admin.spec.ts
Normal file
235
e2e/tests/us3-users-admin.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* US-3 — admin manages user accounts.
|
||||
* Covers AC-3.1 → AC-3.7. RBAC matrix exercised at the API for each verb,
|
||||
* and the /admin/users page is exercised in the UI for create + role-gate.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
makeClient,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us3-redteam';
|
||||
const SOC_USER = 'us3-soc';
|
||||
const PASS = 'us3-pass-strong';
|
||||
|
||||
test.describe('US-3 — user admin', () => {
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
await ensureUser(SOC_USER, PASS, 'soc');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const token = await adminToken();
|
||||
for (const u of [
|
||||
REDTEAM_USER,
|
||||
SOC_USER,
|
||||
'us3-created-via-api',
|
||||
'us3-patched',
|
||||
'us3-deleted',
|
||||
'us3-ui-newuser',
|
||||
]) {
|
||||
await deleteUserByUsername(token, u);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-3.1 — GET /api/users returns list for admin', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
const r = await client.get('/users');
|
||||
expect(r.status).toBe(200);
|
||||
expect(Array.isArray(r.data)).toBe(true);
|
||||
const sample = (r.data as Array<Record<string, unknown>>).find(
|
||||
(u) => u.username === REDTEAM_USER,
|
||||
);
|
||||
expect(sample).toBeTruthy();
|
||||
expect(sample).toMatchObject({ role: 'redteam' });
|
||||
expect(sample!.password_hash).toBeUndefined();
|
||||
expect(sample!.id).toBeDefined();
|
||||
expect(sample!.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
test('AC-3.2 — POST /api/users creates user (201); 400 on duplicate or short password', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
|
||||
const created = await client.post('/users', {
|
||||
username: 'us3-created-via-api',
|
||||
password: 'longenough8',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.data).toMatchObject({
|
||||
username: 'us3-created-via-api',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(created.data.password_hash).toBeUndefined();
|
||||
|
||||
const dup = await client.post('/users', {
|
||||
username: 'us3-created-via-api',
|
||||
password: 'longenough8',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(dup.status).toBe(400);
|
||||
|
||||
const short = await client.post('/users', {
|
||||
username: 'us3-shortpw-x',
|
||||
password: 'short7',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(short.status).toBe(400);
|
||||
});
|
||||
|
||||
test('AC-3.3 — PATCH /api/users/<id> updates role and/or password', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
|
||||
// Seed a fresh user.
|
||||
const created = await client.post('/users', {
|
||||
username: 'us3-patched',
|
||||
password: 'initialpass8',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.data.id as number;
|
||||
|
||||
// PATCH role.
|
||||
const r1 = await client.patch(`/users/${id}`, { role: 'redteam' });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.data.role).toBe('redteam');
|
||||
|
||||
// PATCH password — must be usable for login.
|
||||
const r2 = await client.patch(`/users/${id}`, { password: 'newstrongpass8' });
|
||||
expect(r2.status).toBe(200);
|
||||
const { token: rotated } = await login('us3-patched', 'newstrongpass8');
|
||||
expect(rotated).toBeTruthy();
|
||||
});
|
||||
|
||||
test('AC-3.4 — DELETE /api/users/<id> returns 204; refuses last admin (409)', async () => {
|
||||
const token = await adminToken();
|
||||
const client = makeClient(token);
|
||||
|
||||
// Create a disposable redteam, delete it → 204.
|
||||
const created = await client.post('/users', {
|
||||
username: 'us3-deleted',
|
||||
password: 'disposable8',
|
||||
role: 'redteam',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.data.id as number;
|
||||
|
||||
const del = await client.delete(`/users/${id}`);
|
||||
expect(del.status).toBe(204);
|
||||
|
||||
// Verify gone.
|
||||
const list = await client.get('/users');
|
||||
const found = (list.data as Array<{ id: number }>).find((u) => u.id === id);
|
||||
expect(found).toBeUndefined();
|
||||
|
||||
// Last-admin protection — list admins and try to delete the only one.
|
||||
const all = await client.get('/users');
|
||||
const admins = (all.data as Array<{ id: number; role: string }>).filter(
|
||||
(u) => u.role === 'admin',
|
||||
);
|
||||
if (admins.length === 1) {
|
||||
const r = await client.delete(`/users/${admins[0].id}`);
|
||||
expect(r.status).toBe(409);
|
||||
} else {
|
||||
// If suite added extra admins, demote-then-delete protection still applies:
|
||||
// we attempt a hypothetical demote of one admin (PATCH to redteam) and the
|
||||
// last one must be refused.
|
||||
// Iterate: keep deleting admins one by one until 1 remains, then assert 409.
|
||||
// (Skipped in well-isolated runs because typical state = 1 admin.)
|
||||
const ids = admins.map((a) => a.id);
|
||||
while (ids.length > 1) {
|
||||
const victim = ids.pop()!;
|
||||
const r = await client.delete(`/users/${victim}`);
|
||||
expect(r.status).toBe(204);
|
||||
}
|
||||
const finalId = ids[0];
|
||||
const r = await client.delete(`/users/${finalId}`);
|
||||
expect(r.status).toBe(409);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-3.5 — redteam and soc receive 403 on user-admin endpoints', async () => {
|
||||
for (const role of ['redteam', 'soc'] as const) {
|
||||
const username = role === 'redteam' ? REDTEAM_USER : SOC_USER;
|
||||
const { token } = await login(username, PASS);
|
||||
const client = makeClient(token);
|
||||
|
||||
const list = await client.get('/users');
|
||||
expect(list.status, `${role} GET /users`).toBe(403);
|
||||
|
||||
const post = await client.post('/users', {
|
||||
username: `${role}-attempt`,
|
||||
password: 'whatever8x',
|
||||
role: 'soc',
|
||||
});
|
||||
expect(post.status, `${role} POST /users`).toBe(403);
|
||||
|
||||
const patch = await client.patch('/users/1', { role: 'soc' });
|
||||
expect(patch.status, `${role} PATCH /users/1`).toBe(403);
|
||||
|
||||
const del = await client.delete('/users/999');
|
||||
expect(del.status, `${role} DELETE /users/999`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-3.6 — /admin/users page lists users + allows create + reset-password + delete', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const token = await adminToken();
|
||||
await seedTokenInStorage(context, token);
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await expect(page.getByRole('heading', { name: /user accounts/i })).toBeVisible();
|
||||
|
||||
// Create new user via the form.
|
||||
const newName = 'us3-ui-newuser';
|
||||
await page.fill('#new-username', newName);
|
||||
await page.fill('#new-password', 'uistrongpw8');
|
||||
await page.selectOption('#new-role', 'soc');
|
||||
await page.getByRole('button', { name: /^create$/i }).click();
|
||||
|
||||
// Row appears.
|
||||
const row = page.getByRole('row', { name: new RegExp(newName) });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Reset password flow opens a sub-form.
|
||||
await row.getByRole('button', { name: /reset password/i }).click();
|
||||
await page.fill(`input[id^="reset-"]`, 'rotatedpass8');
|
||||
await page.getByRole('button', { name: /save password/i }).click();
|
||||
await expect(page.getByTestId('toast').filter({ hasText: /password reset/i })).toBeVisible();
|
||||
|
||||
// Delete row (confirm dialog).
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await row.getByRole('button', { name: /^delete$/i }).click();
|
||||
await expect(page.getByRole('row', { name: new RegExp(newName) })).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('AC-3.7 — redteam/soc visiting /admin/users → redirected to /engagements + "Accès refusé" toast', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const { token } = await login(SOC_USER, PASS);
|
||||
await seedTokenInStorage(context, token);
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.waitForURL(/\/engagements\b/, { timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId('toast').filter({ hasText: /accès refusé/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
270
e2e/tests/us4-engagements.spec.ts
Normal file
270
e2e/tests/us4-engagements.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* US-4 — engagement CRUD + RBAC + UI surfaces.
|
||||
* Covers AC-4.1 → AC-4.9.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteAllEngagements,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
listEngagements,
|
||||
login,
|
||||
makeClient,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us4-redteam';
|
||||
const SOC_USER = 'us4-soc';
|
||||
const PASS = 'us4-pass-strong';
|
||||
|
||||
test.describe('US-4 — engagement CRUD', () => {
|
||||
let redteamToken: string;
|
||||
let socToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
await ensureUser(SOC_USER, PASS, 'soc');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
socToken = (await login(SOC_USER, PASS)).token;
|
||||
// Clean slate so AC-4.7 list assertions are predictable.
|
||||
await deleteAllEngagements(await adminToken());
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteAllEngagements(tok);
|
||||
for (const u of [REDTEAM_USER, SOC_USER]) {
|
||||
await deleteUserByUsername(tok, u);
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-4.1 — GET /api/engagements returns serialized list (created_by = {id, username})', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.1 sample',
|
||||
start_date: '2026-02-01',
|
||||
});
|
||||
const items = await listEngagements(redteamToken);
|
||||
const row = items.find((i) => i.id === seeded.id);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row).toMatchObject({
|
||||
name: 'AC-4.1 sample',
|
||||
status: 'planned',
|
||||
start_date: '2026-02-01',
|
||||
});
|
||||
expect(row!.created_by).toMatchObject({ username: REDTEAM_USER });
|
||||
expect(typeof row!.created_by!.id).toBe('number');
|
||||
});
|
||||
|
||||
test('AC-4.2 — POST validates name/dates/status', async () => {
|
||||
const client = makeClient(redteamToken);
|
||||
|
||||
const blankName = await client.post('/engagements', {
|
||||
name: '',
|
||||
start_date: '2026-03-01',
|
||||
});
|
||||
expect(blankName.status).toBe(400);
|
||||
|
||||
const noStart = await client.post('/engagements', { name: 'x' });
|
||||
expect(noStart.status).toBe(400);
|
||||
|
||||
const badDate = await client.post('/engagements', {
|
||||
name: 'x',
|
||||
start_date: 'not-a-date',
|
||||
});
|
||||
expect(badDate.status).toBe(400);
|
||||
|
||||
const endBeforeStart = await client.post('/engagements', {
|
||||
name: 'x',
|
||||
start_date: '2026-04-10',
|
||||
end_date: '2026-04-01',
|
||||
});
|
||||
expect(endBeforeStart.status).toBe(400);
|
||||
|
||||
const badStatus = await client.post('/engagements', {
|
||||
name: 'x',
|
||||
start_date: '2026-04-01',
|
||||
status: 'frozen',
|
||||
});
|
||||
expect(badStatus.status).toBe(400);
|
||||
|
||||
const defaultStatus = await client.post('/engagements', {
|
||||
name: 'AC-4.2 default-status',
|
||||
start_date: '2026-04-01',
|
||||
});
|
||||
expect(defaultStatus.status).toBe(201);
|
||||
expect(defaultStatus.data.status).toBe('planned');
|
||||
});
|
||||
|
||||
test('AC-4.3 — GET /api/engagements/<id> returns 200 + object, 404 if unknown', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.3 sample',
|
||||
start_date: '2026-05-01',
|
||||
});
|
||||
const client = makeClient(redteamToken);
|
||||
const ok = await client.get(`/engagements/${seeded.id}`);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.data.id).toBe(seeded.id);
|
||||
|
||||
const missing = await client.get('/engagements/999999');
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
|
||||
test('AC-4.4 — PATCH (admin/redteam) updates fields', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.4 orig',
|
||||
start_date: '2026-06-01',
|
||||
});
|
||||
const client = makeClient(redteamToken);
|
||||
const r = await client.patch(`/engagements/${seeded.id}`, {
|
||||
name: 'AC-4.4 updated',
|
||||
status: 'active',
|
||||
end_date: '2026-06-15',
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data).toMatchObject({
|
||||
name: 'AC-4.4 updated',
|
||||
status: 'active',
|
||||
end_date: '2026-06-15',
|
||||
});
|
||||
});
|
||||
|
||||
test('AC-4.5 — DELETE (admin/redteam) returns 204', async () => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.5 disposable',
|
||||
start_date: '2026-07-01',
|
||||
});
|
||||
const client = makeClient(redteamToken);
|
||||
const r = await client.delete(`/engagements/${seeded.id}`);
|
||||
expect(r.status).toBe(204);
|
||||
|
||||
const after = await client.get(`/engagements/${seeded.id}`);
|
||||
expect(after.status).toBe(404);
|
||||
});
|
||||
|
||||
test('AC-4.6 — soc can read but not write (403 on POST/PATCH/DELETE)', async () => {
|
||||
const socClient = makeClient(socToken);
|
||||
const list = await socClient.get('/engagements');
|
||||
expect(list.status).toBe(200);
|
||||
|
||||
const post = await socClient.post('/engagements', {
|
||||
name: 'soc-blocked',
|
||||
start_date: '2026-08-01',
|
||||
});
|
||||
expect(post.status).toBe(403);
|
||||
|
||||
// Seed via redteam to get a target id.
|
||||
const target = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.6 target',
|
||||
start_date: '2026-08-15',
|
||||
});
|
||||
|
||||
const patch = await socClient.patch(`/engagements/${target.id}`, { name: 'soc-edit' });
|
||||
expect(patch.status).toBe(403);
|
||||
|
||||
const del = await socClient.delete(`/engagements/${target.id}`);
|
||||
expect(del.status).toBe(403);
|
||||
|
||||
// Clean up via redteam.
|
||||
await deleteEngagement(redteamToken, target.id);
|
||||
});
|
||||
|
||||
test('AC-4.7 — /engagements page lists rows with required columns + role-aware buttons', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Seed one row visible to the redteam user.
|
||||
await createEngagement(redteamToken, {
|
||||
name: 'UI list sample',
|
||||
start_date: '2026-09-01',
|
||||
status: 'active',
|
||||
});
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
|
||||
// Column headers
|
||||
for (const h of ['Name', 'Status', 'Start', 'End', 'Created by']) {
|
||||
await expect(page.getByRole('columnheader', { name: new RegExp(h, 'i') })).toBeVisible();
|
||||
}
|
||||
// The row + status badge + created_by visible
|
||||
const row = page.getByRole('row', { name: /UI list sample/i });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
|
||||
|
||||
// Redteam sees the action buttons.
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible();
|
||||
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
|
||||
|
||||
// Soc should NOT see write buttons.
|
||||
await seedTokenInStorage(context, socToken);
|
||||
await page.goto('/engagements');
|
||||
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
|
||||
await expect(rowAsSoc).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AC-4.8 — /engagements/new form: client validation + API error display', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
|
||||
await page.goto('/engagements/new');
|
||||
await expect(page.getByRole('heading', { name: /new engagement/i })).toBeVisible();
|
||||
|
||||
// Submit empty → client-side errors visible.
|
||||
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||
await expect(page.getByText(/name is required/i)).toBeVisible();
|
||||
await expect(page.getByText(/start date is required/i)).toBeVisible();
|
||||
|
||||
// Fill bad date order → client validation flags end_date.
|
||||
await page.fill('#eng-name', 'UI form test');
|
||||
await page.fill('#eng-start', '2026-10-10');
|
||||
await page.fill('#eng-end', '2026-10-01');
|
||||
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||
await expect(page.getByText(/end date must be on or after start date/i)).toBeVisible();
|
||||
|
||||
// Fix dates → submit succeeds, redirects to detail.
|
||||
await page.fill('#eng-end', '2026-10-20');
|
||||
await page.getByRole('button', { name: /create engagement/i }).click();
|
||||
await page.waitForURL(/\/engagements\/\d+$/);
|
||||
await expect(page.getByRole('heading', { name: /UI form test/i })).toBeVisible();
|
||||
|
||||
// Edit path: navigate to /edit and tweak.
|
||||
const detailUrl = page.url();
|
||||
const id = Number(detailUrl.split('/').pop());
|
||||
await page.goto(`/engagements/${id}/edit`);
|
||||
await expect(page.getByRole('heading', { name: /edit engagement/i })).toBeVisible();
|
||||
await page.fill('#eng-name', 'UI form test (edited)');
|
||||
await page.getByRole('button', { name: /save changes/i }).click();
|
||||
await page.waitForURL(new RegExp(`/engagements/${id}$`));
|
||||
await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-4.9 — /engagements/<id> detail page shows Sprint 2 placeholder', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const seeded = await createEngagement(redteamToken, {
|
||||
name: 'AC-4.9 detail target',
|
||||
start_date: '2026-11-01',
|
||||
description: 'A description for detail rendering.',
|
||||
});
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${seeded.id}`);
|
||||
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/simulations à venir au sprint 2/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
161
e2e/tests/us5-design.spec.ts
Normal file
161
e2e/tests/us5-design.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* US-5 — UI respects DESIGN.md tokens, layout doesn't break at 1280×720,
|
||||
* and loading/error/empty states are wired.
|
||||
*
|
||||
* Pixel-perfect fidelity is NOT enforced — instead we assert that the
|
||||
* tokenised classes from tailwind.config.ts are present and that the canvas
|
||||
* has no horizontal overflow at the design viewport.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
deleteAllEngagements,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us5-redteam';
|
||||
const PASS = 'us5-passw0rd';
|
||||
|
||||
test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
|
||||
let redteamToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteAllEngagements(tok);
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-5.1 — DESIGN.md tokens applied (Inter font, brand palette, named utilities)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
// Body font must resolve to a stack including "Inter".
|
||||
const fontFamily = await page.evaluate(
|
||||
() => window.getComputedStyle(document.body).fontFamily,
|
||||
);
|
||||
expect(fontFamily.toLowerCase()).toMatch(/inter/);
|
||||
|
||||
// Tailwind-compiled DESIGN palette: the primary chevron at the top-left of
|
||||
// the nav uses `bg-primary` → rendered as rgb(2, 74, 216).
|
||||
const chevronBg = await page
|
||||
.locator('header a[aria-label="Mimic home"] span[aria-hidden]')
|
||||
.first()
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)');
|
||||
|
||||
// Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)).
|
||||
const utilityBg = await page
|
||||
.locator('div.bg-ink.text-ink-on')
|
||||
.first()
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)');
|
||||
|
||||
// Spot-check a few semantic class names live in the DOM (proves tokens are
|
||||
// wired through tailwind.config.ts and not ad-hoc hex values).
|
||||
await expect(page.locator('.btn-primary, .card-product, .max-w-page').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-5.2 — no horizontal overflow at 1280×720 across key pages', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
const routes = ['/engagements', '/engagements/new'];
|
||||
for (const route of routes) {
|
||||
await page.goto(route);
|
||||
const overflow = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(
|
||||
overflow.scrollWidth,
|
||||
`${route} should not horizontally overflow at 1280px`,
|
||||
).toBeLessThanOrEqual(overflow.clientWidth + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-5.3a — empty state renders when engagements list is empty', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
const tok = await adminToken();
|
||||
await deleteAllEngagements(tok);
|
||||
await page.goto('/engagements');
|
||||
await expect(page.getByRole('heading', { name: /no engagements yet/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-5.3b — loading state renders while engagements list is in-flight', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
|
||||
// Stall the list endpoint so the LoadingState becomes visible.
|
||||
const handler = async (route: import('@playwright/test').Route) => {
|
||||
const url = route.request().url();
|
||||
if (/\/api\/engagements(\?.*)?$/.test(url)) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
try {
|
||||
await route.continue();
|
||||
} catch {
|
||||
/* route may have been torn down by the time we resume */
|
||||
}
|
||||
};
|
||||
await page.route('**/api/engagements**', handler);
|
||||
|
||||
const navPromise = page.goto('/engagements');
|
||||
await expect(page.getByText(/loading engagements/i)).toBeVisible({ timeout: 3_000 });
|
||||
await navPromise;
|
||||
await page.unroute('**/api/engagements**', handler);
|
||||
});
|
||||
|
||||
test('AC-5.3c — error state renders when the engagements list returns 500', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
|
||||
const handler = async (route: import('@playwright/test').Route) => {
|
||||
const url = route.request().url();
|
||||
if (/\/api\/engagements(\?.*)?$/.test(url)) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Forced failure' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await route.continue();
|
||||
} catch {
|
||||
/* route teardown race — harmless */
|
||||
}
|
||||
};
|
||||
await page.route('**/api/engagements**', handler);
|
||||
|
||||
await page.goto('/engagements');
|
||||
await expect(page.getByTestId('error-state')).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.getByTestId('error-state')).toContainText(/forced failure/i);
|
||||
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
|
||||
await page.unroute('**/api/engagements**', handler);
|
||||
});
|
||||
});
|
||||
117
e2e/tests/us6-deployment.spec.ts
Normal file
117
e2e/tests/us6-deployment.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* US-6 — deployment via Makefile + Docker.
|
||||
*
|
||||
* These checks are infrastructure-level: they shell out to `make` and the
|
||||
* container runtime to assert the build/run targets behave correctly and
|
||||
* the SQLite volume survives a restart.
|
||||
*
|
||||
* The container is expected to already be `up` when the suite starts (the
|
||||
* harness runs `make build && make start && make create-admin` before
|
||||
* Playwright). So `AC-6.1`/`AC-6.2` are verified via image presence + HTTP
|
||||
* smoke; `AC-6.3` exercises stop/restart/logs; `AC-6.4` writes a row, restarts,
|
||||
* and re-reads it; `AC-6.5` confirms the test targets exist as Makefile rules.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteAllEngagements,
|
||||
listEngagements,
|
||||
waitForHealth,
|
||||
} from '../fixtures/api';
|
||||
|
||||
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker';
|
||||
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
|
||||
const IMAGE = process.env.MIMIC_IMAGE ?? 'mimic:latest';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '../..');
|
||||
|
||||
function run(cmd: string, opts: { ignoreFail?: boolean } = {}): { status: number; out: string } {
|
||||
try {
|
||||
const out = execSync(cmd, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return { status: 0, out };
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
const out =
|
||||
(typeof err.stdout === 'string' ? err.stdout : err.stdout?.toString() ?? '') +
|
||||
(typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '');
|
||||
if (opts.ignoreFail) return { status: err.status ?? -1, out };
|
||||
throw new Error(`command failed (${err.status}): ${cmd}\n${out}`);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US-6 — deployment via Docker + Makefile', () => {
|
||||
test('AC-6.1 — image mimic:latest exists (built by make build)', () => {
|
||||
const r = run(`${RUNTIME} images --format "{{.Repository}}:{{.Tag}}"`);
|
||||
expect(r.out).toMatch(new RegExp(IMAGE.replace(/[.:]/g, '\\$&')));
|
||||
});
|
||||
|
||||
test('AC-6.2 — container responds on http://localhost:5000 (front + /api/health)', async () => {
|
||||
await waitForHealth(5_000);
|
||||
// Frontend is served at "/". Use 127.0.0.1 explicitly so envs where
|
||||
// `localhost` resolves to ::1 (where the container port isn't bound)
|
||||
// don't break this contract test.
|
||||
const base = (process.env.MIMIC_BASE_URL ?? 'http://127.0.0.1:5000').replace(/\/$/, '');
|
||||
const html = run(`curl -fsS ${base}/`).out;
|
||||
expect(html).toMatch(/<!doctype html>/i);
|
||||
expect(html.toLowerCase()).toMatch(/mimic/);
|
||||
});
|
||||
|
||||
test('AC-6.3 — make stop / make restart / make logs are well-formed targets', () => {
|
||||
// Don't actually stop the container mid-suite — that would tear down
|
||||
// the other tests. Instead, verify the Makefile rules exist and are
|
||||
// syntactically valid by asking make for their recipes via `--just-print`.
|
||||
// `make restart` expands to `make stop && make start`, so we look for
|
||||
// those sub-commands instead of a literal "restart" token.
|
||||
const dry = run('make --dry-run --no-print-directory stop restart logs');
|
||||
expect(dry.out).toMatch(new RegExp(`${RUNTIME} stop ${CONTAINER}`));
|
||||
expect(dry.out).toMatch(/make stop && make start/);
|
||||
expect(dry.out).toMatch(new RegExp(`${RUNTIME} logs -f ${CONTAINER}`));
|
||||
|
||||
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
|
||||
expect(makefile).toMatch(/^stop:/m);
|
||||
expect(makefile).toMatch(/^restart:/m);
|
||||
expect(makefile).toMatch(/^logs:/m);
|
||||
});
|
||||
|
||||
test('AC-6.4 — SQLite persists across container restart (named volume mimic-data)', async () => {
|
||||
const token = await adminToken();
|
||||
// Seed a unique engagement.
|
||||
const marker = `AC-6.4-persistence-${Date.now()}`;
|
||||
await createEngagement(token, { name: marker, start_date: '2026-12-01' });
|
||||
|
||||
// Restart the container (NOT make restart, since that would also rm —
|
||||
// we do `runtime restart` which keeps the same container + volume).
|
||||
run(`${RUNTIME} restart ${CONTAINER}`);
|
||||
await waitForHealth(20_000);
|
||||
|
||||
const token2 = await adminToken();
|
||||
const items = await listEngagements(token2);
|
||||
const found = items.find((i) => i.name === marker);
|
||||
expect(found, `engagement seeded before restart should survive`).toBeTruthy();
|
||||
|
||||
// Cleanup.
|
||||
await deleteAllEngagements(token2);
|
||||
});
|
||||
|
||||
test('AC-6.5 — make test-backend / test-frontend / test-e2e are defined', () => {
|
||||
const makefile = readFileSync(resolve(REPO_ROOT, 'Makefile'), 'utf8');
|
||||
expect(makefile).toMatch(/^test-backend:/m);
|
||||
expect(makefile).toMatch(/^test-frontend:/m);
|
||||
expect(makefile).toMatch(/^test-e2e:/m);
|
||||
|
||||
// Dry-run them to make sure the recipes are syntactically valid.
|
||||
const dry = run('make --dry-run --no-print-directory test-backend test-frontend test-e2e');
|
||||
expect(dry.out).toMatch(/pytest/);
|
||||
expect(dry.out).toMatch(/npm run test/);
|
||||
expect(dry.out).toMatch(/playwright test/);
|
||||
});
|
||||
});
|
||||
18
e2e/tsconfig.json
Normal file
18
e2e/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "@playwright/test"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fixtures/*": ["fixtures/*"],
|
||||
"@helpers/*": ["helpers/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user