Files
mimic/e2e/tests/us3-users-admin.spec.ts

236 lines
7.8 KiB
TypeScript
Raw Normal View History

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>
2026-05-26 09:37:53 +02:00
/**
* 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();
});
});