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:
321
e2e/tests/m6-missions.spec.ts
Normal file
321
e2e/tests/m6-missions.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user