import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; /** * M6 — Mission CRUD, snapshot fidelity, membership visibility, transitions. * * The suite covers: * - Snapshot independence (mutating a template after mission creation must NOT * propagate into the mission's snapshot). * - Membership visibility (non-admin viewers see only their own missions). * - Status transition state machine (draft → in_progress → completed → archived). * - SPA: list + 3-step create wizard + detail page tabs. * * Template + MITRE seed are pulled in `beforeAll`; the `afterAll` hook restores * the stable admin (memory rule `feedback-metamorph-test-admin`) and re-seeds * MITRE so subsequent manual sessions don't see an empty matrix. */ const ADMIN_EMAIL = `m6-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 loginAndGetAccess( 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 loginViaSpa(page: Page, email: string, password: string) { await page.goto('/login'); await page.getByLabel(/email/i).fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(email); } test.describe.configure({ mode: 'serial' }); test.describe('M6 — Missions', () => { 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 loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const sync = await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${access}` }, }); expect(sync.status()).toBe(200); }); 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 loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!'); await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${access}` }, }); }); // ---------- helpers ---------------------------------------------------- async function adminAuth(request: APIRequestContext): Promise> { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); return { Authorization: `Bearer ${access}` }; } async function makeTest( request: APIRequestContext, auth: Record, name: string, mitre = 'T1059', ): Promise { const r = await request.post('/api/v1/test-templates', { headers: auth, data: { name, mitre_tags: [{ kind: 'technique', external_id: mitre }], }, }); expect(r.status(), await r.text()).toBe(201); return (await r.json()).id as string; } async function makeScenario( request: APIRequestContext, auth: Record, name: string, testIds: string[], ): Promise { const r = await request.post('/api/v1/scenario-templates', { headers: auth, data: { name, test_template_ids: testIds }, }); expect(r.status(), await r.text()).toBe(201); return (await r.json()).id as string; } // ---------- API: snapshot fidelity ------------------------------------ test('Snapshot freezes scenario + test fields at creation time', async ({ request }) => { const auth = await adminAuth(request); const tid = await makeTest(request, auth, 'snap-t1'); const sid = await makeScenario(request, auth, 'snap-scenario', [tid]); const create = await request.post('/api/v1/missions', { headers: auth, data: { name: 'snap-mission', client_target: 'Acme', scenario_template_ids: [sid], }, }); expect(create.status(), await create.text()).toBe(201); const mission = await create.json(); expect(mission.scenarios_count).toBe(1); expect(mission.tests_count).toBe(1); expect(mission.scenarios[0].tests[0].snapshot_name).toBe('snap-t1'); // Mutate the source template AFTER snapshot const edit = await request.put(`/api/v1/test-templates/${tid}`, { headers: auth, data: { name: 'RENAMED-LATER', mitre_tags: [{ kind: 'tactic', external_id: 'TA0002' }], }, }); expect(edit.status()).toBe(200); // Mission still sees the pre-edit snapshot const refetch = await request.get(`/api/v1/missions/${mission.id}`, { headers: auth }); expect(refetch.status()).toBe(200); const snapshot = await refetch.json(); expect(snapshot.scenarios[0].tests[0].snapshot_name).toBe('snap-t1'); expect( snapshot.scenarios[0].tests[0].mitre_tags.map( (t: { external_id: string }) => t.external_id, ), ).toEqual(['T1059']); }); // ---------- API: membership visibility -------------------------------- test('Non-admin members see only missions they belong to', async ({ request }) => { const auth = await adminAuth(request); // Create a group with mission.* perms and invite a "red" user. const grp = await request .post('/api/v1/groups', { headers: auth, data: { name: 'm6-red-grp' } }) .then((r) => r.json()); const setPerms = await request.put(`/api/v1/groups/${grp.id}/permissions`, { headers: auth, data: { codes: ['mission.read', 'mission.create', 'mission.update', 'mission.archive'], }, }); expect(setPerms.status()).toBe(200); const redEmail = `m6-red-${crypto.randomUUID().slice(0, 8)}@metamorph.local`; const redPwd = 'RedPass1234!'; const inv = await request .post('/api/v1/invitations', { headers: auth, data: { email_hint: redEmail, group_ids: [grp.id] }, }) .then((r) => r.json()); const accept = await request.post(`/api/v1/invitations/accept/${inv.token}`, { data: { email: redEmail, password: redPwd }, }); expect(accept.status()).toBe(201); const redAccess = await loginAndGetAccess(request, redEmail, redPwd); const redAuth = { Authorization: `Bearer ${redAccess}` }; // Admin creates a mission with NO members → red should not see it. const hidden = await request .post('/api/v1/missions', { headers: auth, data: { name: 'm6-admin-hidden' }, }) .then((r) => r.json()); const redList = await request.get('/api/v1/missions', { headers: redAuth }); expect(redList.status()).toBe(200); const visible = (await redList.json()).items.map((it: { name: string }) => it.name); expect(visible).not.toContain('m6-admin-hidden'); const redGetHidden = await request.get(`/api/v1/missions/${hidden.id}`, { headers: redAuth, }); expect(redGetHidden.status()).toBe(404); // Red creates their own mission — auto-added as member → visible to them. const ownResp = await request.post('/api/v1/missions', { headers: redAuth, data: { name: 'm6-red-own' }, }); expect(ownResp.status(), await ownResp.text()).toBe(201); const own = await ownResp.json(); expect(own.members.map((m: { user_id: string }) => m.user_id)).toContain( own.members[0].user_id, ); const redListAfter = await request.get('/api/v1/missions', { headers: redAuth }); const namesAfter = (await redListAfter.json()).items.map( (it: { name: string }) => it.name, ); expect(namesAfter).toContain('m6-red-own'); }); // ---------- API: transitions ------------------------------------------ test('Status transition chain and rejection of invalid jumps', async ({ request }) => { const auth = await adminAuth(request); const m = await request .post('/api/v1/missions', { headers: auth, data: { name: 'm6-status-chain' }, }) .then((r) => r.json()); for (const target of ['in_progress', 'completed', 'archived']) { const r = await request.post(`/api/v1/missions/${m.id}/transition`, { headers: auth, data: { status: target }, }); expect(r.status(), await r.text()).toBe(200); expect((await r.json()).status).toBe(target); } // Re-create + try an invalid jump draft → completed (must be 409) const m2 = await request .post('/api/v1/missions', { headers: auth, data: { name: 'm6-status-jump' }, }) .then((r) => r.json()); const bad = await request.post(`/api/v1/missions/${m2.id}/transition`, { headers: auth, data: { status: 'completed' }, }); expect(bad.status()).toBe(409); expect((await bad.json()).error).toBe('invalid_transition'); }); // ---------- SPA ------------------------------------------------------- test('SPA — admin creates a mission via the 3-step wizard', async ({ page, request }) => { const auth = await adminAuth(request); const ids: string[] = []; for (const name of ['spa-wizard-t1', 'spa-wizard-t2', 'spa-wizard-t3']) { ids.push(await makeTest(request, auth, name)); } const sid = await makeScenario(request, auth, 'spa-wizard-scenario', ids); await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/missions'); await page.getByTestId('missions-new-link').click(); await expect(page).toHaveURL(/\/missions\/new$/); // Step 1 — Metadata await page.getByTestId('meta-name').fill('spa-wizard-mission'); await page.getByTestId('meta-client').fill('Acme via SPA'); await page.getByTestId('missions-create-next').click(); // Step 2 — Scenarios await page.getByTestId(`scenario-toggle-${sid}`).click(); await page.getByTestId('missions-create-next').click(); // Step 3 — Members (admin doesn't need to add themselves; submit straight away) await page.getByTestId('missions-create-submit').click(); // Should land on the detail page await expect(page).toHaveURL(/\/missions\/[0-9a-f-]+$/); await expect(page.getByTestId('mission-transition-in_progress')).toBeVisible(); await expect(page.getByTestId('mission-tab-tests')).toBeVisible(); // Tests tab renders 3 snapshotted tests await expect(page.getByText('spa-wizard-t1')).toBeVisible(); await expect(page.getByText('spa-wizard-t2')).toBeVisible(); await expect(page.getByText('spa-wizard-t3')).toBeVisible(); }); test('SPA — detail page edits metadata, appends scenarios, edits members', async ({ page, request, }) => { const auth = await adminAuth(request); // Pre-seed: one mission with one initial scenario; a second scenario to // append; and a second user we can assign as a member from the SPA. const initialTestId = await makeTest(request, auth, 'spa-edit-initial-t'); const initialScenarioId = await makeScenario( request, auth, 'spa-edit-initial-scenario', [initialTestId], ); const extraTestId = await makeTest(request, auth, 'spa-edit-appended-t'); const extraScenarioId = await makeScenario( request, auth, 'spa-edit-appended-scenario', [extraTestId], ); const mission = await request .post('/api/v1/missions', { headers: auth, data: { name: 'spa-edit-target', client_target: 'Initial Co.', scenario_template_ids: [initialScenarioId], }, }) .then((r) => r.json()); // A second user the admin can add as a member via the modal. const teammateEmail = `spa-edit-mate-${crypto.randomUUID().slice(0, 8)}@metamorph.local`; const inv = await request .post('/api/v1/invitations', { headers: auth, data: { email_hint: teammateEmail }, }) .then((r) => r.json()); await request.post(`/api/v1/invitations/accept/${inv.token}`, { data: { email: teammateEmail, password: 'MatePass1234!' }, }); await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto(`/missions/${mission.id}`); await expect(page.getByText('Initial Co.')).toBeVisible(); // --- Edit metadata -------------------------------------------------- await page.getByTestId('mission-edit-meta').click(); const metaModal = page.getByTestId('mission-edit-meta-modal'); await expect(metaModal).toBeVisible(); await metaModal.getByTestId('meta-edit-client').fill('Renamed Co.'); await metaModal.getByTestId('meta-edit-save').click(); await expect(metaModal).toBeHidden(); await expect(page.getByText('Renamed Co.')).toBeVisible(); // --- Append a scenario --------------------------------------------- await page.getByTestId('mission-add-scenarios').click(); const addModal = page.getByTestId('mission-add-scenarios-modal'); await expect(addModal).toBeVisible(); await addModal.getByTestId(`add-scenario-toggle-${extraScenarioId}`).click(); await addModal.getByTestId('add-scenarios-save').click(); await expect(addModal).toBeHidden(); // Both scenarios now visible in the Tests tab await expect(page.getByText('spa-edit-initial-scenario')).toBeVisible(); await expect(page.getByText('spa-edit-appended-scenario')).toBeVisible(); await expect(page.getByText('spa-edit-appended-t')).toBeVisible(); // --- Edit members --------------------------------------------------- await page.getByTestId('mission-tab-members').click(); await page.getByTestId('mission-edit-members').click(); const memModal = page.getByTestId('mission-edit-members-modal'); await expect(memModal).toBeVisible(); // The roster row test-ids encode the new user's id; we don't know it here // but the email is unique, so locate the row by email text and toggle red. const teammateRow = memModal.getByText(teammateEmail).locator('..').locator('..'); await teammateRow.getByRole('button', { name: /red/i }).click(); await memModal.getByTestId('edit-members-save').click(); await expect(memModal).toBeHidden(); await expect(page.getByText(teammateEmail)).toBeVisible(); }); test('SPA — list page filters by status', async ({ page, request }) => { const auth = await adminAuth(request); // Seed two missions with distinct statuses. const m1 = await request .post('/api/v1/missions', { headers: auth, data: { name: 'filter-draft' } }) .then((r) => r.json()); const m2 = await request .post('/api/v1/missions', { headers: auth, data: { name: 'filter-active' } }) .then((r) => r.json()); await request.post(`/api/v1/missions/${m2.id}/transition`, { headers: auth, data: { status: 'in_progress' }, }); await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/missions'); await expect(page.getByText('filter-draft')).toBeVisible(); await expect(page.getByText('filter-active')).toBeVisible(); await page.getByTestId('missions-filter-status').selectOption('in_progress'); await expect(page.getByText('filter-active')).toBeVisible(); await expect(page.getByText('filter-draft')).toBeHidden(); // Sanity: m1 / m2 ids should match what the list-card test-id encodes. await expect(page.getByTestId(`mission-card-${m2.id}`)).toBeVisible(); await expect(page.getByTestId(`mission-card-${m1.id}`)).toBeHidden(); }); });