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>
271 lines
9.3 KiB
TypeScript
271 lines
9.3 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|