feat(m6): missions + snapshot CRUD, membership visibility, status state machine

Adds the mission layer that materialises template snapshots, plus the SPA
list / 3-step wizard / detail page.

Backend:
- app/services/missions.py — create_mission snapshots scenarios, tests, MITRE
  tags in a 4-query write; list/get apply a non-admin membership filter that
  collapses to 404 (no existence leak); status state machine enforces
  draft → in_progress → completed → archived with archived as a sink; the
  non-admin creator is auto-added as role_hint='red' to retain visibility.
- app/api/missions.py — 8 endpoints (list, get, create, update, add
  scenarios, set members, transition, soft-delete) with strict pydantic
  schemas. The transition endpoint splits the perm gate manually so
  archive requires mission.archive while other targets use mission.update.
- app/api/users.py — new GET /users/roster returning (id, email,
  display_name) only, gated by user.read OR mission.create OR
  mission.update — lets non-admin wizard users see assignable peers
  without exposing the admin /users payload.
- app/api/diag.py — /diag/reset truncates the mission_* tables before the
  template tables because the source_*_template_id FKs are ON DELETE SET
  NULL, which is cheaper to short-circuit by removing the children first.

Frontend:
- lib/missions.ts — typed client, queryKey factory, status accent map.
- pages/MissionsListPage.tsx — list cards with status accent + filters
  (q, client, status).
- pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members)
  with member roster fed by /users/roster.
- pages/MissionDetailPage.tsx — header + transition buttons (legal next
  states only) + Tests/Members/Synthesis/Export tabs.
- Routes + nav entry (visible to anyone with mission.read or admin).

Tests:
- backend/tests/test_missions.py — 22 pytest covering snapshot fidelity,
  MITRE propagation, membership visibility, transition state machine,
  perm gating, member set replace, append scenarios, soft-delete, partial
  update, inverted-date rejection.
- e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin
  visibility, status transitions + 409, SPA wizard end-to-end, list filter).

Docs:
- CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs,
  membership=404 pattern, /diag/reset order, auto-creator add).
- README + tasks/todo.md updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-13 15:07:32 +02:00
parent a57d91f176
commit 00b7557e30
18 changed files with 3714 additions and 4 deletions

View File

@@ -0,0 +1,321 @@
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<string> {
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<string> {
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<Record<string, string>> {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
return { Authorization: `Bearer ${access}` };
}
async function makeTest(
request: APIRequestContext,
auth: Record<string, string>,
name: string,
mitre = 'T1059',
): Promise<string> {
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<string, string>,
name: string,
testIds: string[],
): Promise<string> {
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.getByRole('button', { name: /In Progress/i })).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 — 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();
});
});