/** * 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>).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/ 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/ 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(); }); });