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>
2026-05-13 15:07:32 +02:00
|
|
|
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-]+$/);
|
2026-05-13 15:14:57 +02:00
|
|
|
await expect(page.getByTestId('mission-transition-in_progress')).toBeVisible();
|
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>
2026-05-13 15:07:32 +02:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|