import { expect, test, type APIRequestContext } from '@playwright/test'; /** * M7 — Red/blue execution on a mission test. * * Scope (cf. tasks/spec.md §M7): * - Field-level perm gating (write_red_fields vs write_blue_fields). * - State machine transitions and side-effect on `executed_at`. * - Evidence upload: 24 MB ok, 26 MB rejected, SHA256 verified. * - Activity polling endpoint surfaces the last actor. * - SPA: the per-test page exposes both zones, accepts a small file via the * dropzone, and shows the "modified by X" badge after a write. * * `afterAll` restores `admin@metamorph.local` / `AdminPass1234!` and re-syncs * MITRE so subsequent manual sessions are not staring at an empty stack. */ const ADMIN_EMAIL = `m7-admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`; const ADMIN_PASSWORD = 'AdminPass1234!'; async function resetAndMintToken(request: APIRequestContext): Promise { const r = await request.post('/api/v1/diag/reset'); expect(r.status()).toBe(200); return (await r.json()).install_token as string; } async function login( request: APIRequestContext, email: string, password: string, ): Promise { const r = await request.post('/api/v1/auth/login', { data: { email, password }, }); expect(r.status()).toBe(200); return (await r.json()).access_token as string; } async function inviteUser( request: APIRequestContext, adminAuth: Record, prefix: string, groupCodes: string[], ): Promise<{ email: string; password: string; token: string; id: string }> { const grp = await request.post('/api/v1/groups', { headers: adminAuth, data: { name: `${prefix}-${crypto.randomUUID().slice(0, 4)}` }, }); expect(grp.status()).toBe(201); const grpId = (await grp.json()).id as string; const setPerms = await request.put(`/api/v1/groups/${grpId}/permissions`, { headers: adminAuth, data: { codes: groupCodes }, }); expect(setPerms.status()).toBe(200); const email = `${prefix}-${crypto.randomUUID().slice(0, 6)}@metamorph.local`; const inv = await request.post('/api/v1/invitations', { headers: adminAuth, data: { email_hint: email, group_ids: [grpId] }, }); expect(inv.status()).toBe(201); const inviteToken = (await inv.json()).token as string; const password = 'Pass1234!'; const accept = await request.post( `/api/v1/invitations/accept/${inviteToken}`, { data: { email, password } }, ); expect(accept.status()).toBe(201); const token = await login(request, email, password); const me = await request.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(me.status()).toBe(200); return { email, password, token, id: (await me.json()).id as string }; } test.describe.configure({ mode: 'serial' }); test.describe('M7 — Test execution', () => { let templateId = ''; let scenarioId = ''; test.beforeAll(async ({ request }) => { const installToken = await resetAndMintToken(request); const setup = await request.post('/api/v1/setup', { data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD, }, }); expect(setup.status()).toBe(201); const access = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD); const sync = await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${access}` }, }); expect(sync.status()).toBe(200); const auth = { Authorization: `Bearer ${access}` }; const t = await request.post('/api/v1/test-templates', { headers: auth, data: { name: 'm7-test', description: 'auto', objective: 'do thing', procedure_md: '# steps', expected_result_red_md: 'red expects', expected_detection_blue_md: 'blue expects', opsec_level: 'medium', tags: [], expected_iocs: [], mitre_tags: [{ kind: 'technique', external_id: 'T1059' }], }, }); expect(t.status()).toBe(201); templateId = (await t.json()).id as string; const sc = await request.post('/api/v1/scenario-templates', { headers: auth, data: { name: 'm7-scenario', description: 'auto', test_template_ids: [templateId], }, }); expect(sc.status()).toBe(201); scenarioId = (await sc.json()).id as string; }); test.afterAll(async ({ request }) => { const installToken = await resetAndMintToken(request); await request.post('/api/v1/setup', { data: { install_token: installToken, email: 'admin@metamorph.local', password: 'AdminPass1234!', }, }); const access = await login( request, 'admin@metamorph.local', 'AdminPass1234!', ); await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${access}` }, }); }); // ----------------------------------------------------------------------- // API — field-level perm gating + state machine // ----------------------------------------------------------------------- test('red-only user cannot write blue fields; blue-only user cannot write red', async ({ request, }) => { const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD); const adminAuth = { Authorization: `Bearer ${adminAccess}` }; const red = await inviteUser(request, adminAuth, 'red', [ 'mission.read', 'mission.create', 'mission.write_red_fields', 'detection_level.read', ]); const blue = await inviteUser(request, adminAuth, 'blue', [ 'mission.read', 'mission.write_blue_fields', 'detection_level.read', ]); const mission = await request.post('/api/v1/missions', { headers: { Authorization: `Bearer ${red.token}` }, data: { name: 'm7-fields', scenario_template_ids: [scenarioId], members: [ { user_id: red.id, role_hint: 'red' }, { user_id: blue.id, role_hint: 'blue' }, ], }, }); expect(mission.status()).toBe(201); const m = await mission.json(); const testId = m.scenarios[0].tests[0].id as string; const redCannotBlue = await request.put( `/api/v1/missions/${m.id}/tests/${testId}`, { headers: { Authorization: `Bearer ${red.token}` }, data: { blue_comment_md: 'should be blocked' }, }, ); expect(redCannotBlue.status()).toBe(403); const blueCannotRed = await request.put( `/api/v1/missions/${m.id}/tests/${testId}`, { headers: { Authorization: `Bearer ${blue.token}` }, data: { red_command: 'should be blocked' }, }, ); expect(blueCannotRed.status()).toBe(403); }); test('mark-executed stamps executed_at and gates reviewed_by_blue to blue side', async ({ request, }) => { const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD); const adminAuth = { Authorization: `Bearer ${adminAccess}` }; const red = await inviteUser(request, adminAuth, 'red', [ 'mission.read', 'mission.create', 'mission.write_red_fields', ]); const blue = await inviteUser(request, adminAuth, 'blue', [ 'mission.read', 'mission.write_blue_fields', ]); const mission = await request.post('/api/v1/missions', { headers: { Authorization: `Bearer ${red.token}` }, data: { name: 'm7-state', scenario_template_ids: [scenarioId], members: [ { user_id: red.id, role_hint: 'red' }, { user_id: blue.id, role_hint: 'blue' }, ], }, }); const m = await mission.json(); const testId = m.scenarios[0].tests[0].id as string; const execute = await request.post( `/api/v1/missions/${m.id}/tests/${testId}/transition`, { headers: { Authorization: `Bearer ${red.token}` }, data: { target_state: 'executed' }, }, ); expect(execute.status()).toBe(200); const executedBody = await execute.json(); expect(executedBody.state).toBe('executed'); expect(executedBody.executed_at).not.toBeNull(); // Red cannot review_by_blue. const redReview = await request.post( `/api/v1/missions/${m.id}/tests/${testId}/transition`, { headers: { Authorization: `Bearer ${red.token}` }, data: { target_state: 'reviewed_by_blue' }, }, ); expect(redReview.status()).toBe(403); const blueReview = await request.post( `/api/v1/missions/${m.id}/tests/${testId}/transition`, { headers: { Authorization: `Bearer ${blue.token}` }, data: { target_state: 'reviewed_by_blue' }, }, ); expect(blueReview.status()).toBe(200); expect((await blueReview.json()).state).toBe('reviewed_by_blue'); }); // ----------------------------------------------------------------------- // API — evidence upload // ----------------------------------------------------------------------- test('evidence upload — 24 MB accepted, 26 MB rejected, SHA256 verified', async ({ request, }) => { const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD); const adminAuth = { Authorization: `Bearer ${adminAccess}` }; const blue = await inviteUser(request, adminAuth, 'blue', [ 'mission.read', 'mission.write_blue_fields', ]); const mission = await request.post('/api/v1/missions', { headers: adminAuth, data: { name: 'm7-evidence', scenario_template_ids: [scenarioId], members: [{ user_id: blue.id, role_hint: 'blue' }], }, }); expect(mission.status()).toBe(201); const m = await mission.json(); const testId = m.scenarios[0].tests[0].id as string; const headerOk = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const tail24 = Buffer.alloc(24 * 1024 * 1024 - headerOk.length, 0x41); const file24 = Buffer.concat([headerOk, tail24]); const expected = await crypto.subtle.digest('SHA-256', file24); const expectedHex = Array.from(new Uint8Array(expected)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); const ok = await request.post( `/api/v1/missions/${m.id}/tests/${testId}/evidence`, { headers: { Authorization: `Bearer ${blue.token}` }, multipart: { file: { name: 'lab.evtx', mimeType: 'application/octet-stream', buffer: file24, }, }, }, ); expect(ok.status()).toBe(201); const body = await ok.json(); expect(body.size_bytes).toBe(file24.length); expect(body.sha256).toBe(expectedHex); const file26 = Buffer.concat([ headerOk, Buffer.alloc(26 * 1024 * 1024 - headerOk.length, 0x41), ]); const tooBig = await request.post( `/api/v1/missions/${m.id}/tests/${testId}/evidence`, { headers: { Authorization: `Bearer ${blue.token}` }, multipart: { file: { name: 'huge.evtx', mimeType: 'application/octet-stream', buffer: file26, }, }, }, ); expect(tooBig.status()).toBe(400); expect((await tooBig.json()).error).toBe('too_large'); const evictGet = await request.get( `/api/v1/evidence/${body.id}?download=true`, { headers: { Authorization: `Bearer ${blue.token}` } }, ); expect(evictGet.status()).toBe(200); const dlBytes = await evictGet.body(); expect(dlBytes.length).toBe(file24.length); }); // ----------------------------------------------------------------------- // SPA — per-test page edits & uploads // ----------------------------------------------------------------------- test('SPA — per-test page: red comment save bumps activity badge', async ({ page, request, }) => { const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD); const adminAuth = { Authorization: `Bearer ${adminAccess}` }; const red = await inviteUser(request, adminAuth, 'red', [ 'mission.read', 'mission.create', 'mission.update', 'mission.write_red_fields', 'detection_level.read', ]); const mission = await request.post('/api/v1/missions', { headers: { Authorization: `Bearer ${red.token}` }, data: { name: 'm7-spa', scenario_template_ids: [scenarioId], members: [{ user_id: red.id, role_hint: 'red' }], }, }); const m = await mission.json(); const testId = m.scenarios[0].tests[0].id as string; // Log the SPA in as the red user. await page.goto('/login'); await page.getByLabel(/email/i).fill(red.email); await page.getByLabel(/password/i).fill(red.password); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(red.email); await page.goto(`/missions/${m.id}/tests/${testId}`); await expect(page.getByTestId('mission-test-page')).toBeVisible(); await expect(page.getByTestId('state-pill')).toContainText(/Not started/); // Fill the red command + comment, then save. Post-amendement 2026-05-15 // bis: writing red fields implicitly promotes the state — no transition // button click required. await page.getByTestId('red-command').fill('whoami /priv'); await page.getByTestId('red-comment').fill('Verified locally'); await page.getByTestId('red-save').click(); await expect(page.getByTestId('state-pill')).toContainText(/Awaiting review/); // The "last touched" line should now mention the red user. await expect(page.locator('text=/Last touched/')).toBeVisible(); }); test('SPA — non-member sees 404 message instead of mission content', async ({ page, request, }) => { const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD); const adminAuth = { Authorization: `Bearer ${adminAccess}` }; const owner = await inviteUser(request, adminAuth, 'own', [ 'mission.read', 'mission.create', 'mission.write_red_fields', ]); const outsider = await inviteUser(request, adminAuth, 'out', [ 'mission.read', ]); const mission = await request.post('/api/v1/missions', { headers: { Authorization: `Bearer ${owner.token}` }, data: { name: 'm7-private', scenario_template_ids: [scenarioId], }, }); const m = await mission.json(); const testId = m.scenarios[0].tests[0].id as string; await page.goto('/login'); await page.getByLabel(/email/i).fill(outsider.email); await page.getByLabel(/password/i).fill(outsider.password); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(outsider.email); await page.goto(`/missions/${m.id}/tests/${testId}`); await expect( page.locator('text=/Mission test not found/'), ).toBeVisible(); }); });