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