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:
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user