Files
Metamorph/e2e/tests/m6-missions.spec.ts

406 lines
16 KiB
TypeScript
Raw Permalink Normal View History

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-]+$/);
fix(m6): post-review pass — cache prefix, snapshot lock, perm-before-parse, LIKE escape Addresses spec-reviewer + code-reviewer feedback on the M6 bundle: Critical: - frontend/src/lib/missions.ts: add `listPrefix()` so TanStack invalidation catches every filtered list variant; the previous `list()` returned `['missions','list',{}]` and only matched the exact empty-filter cache, leaving filtered tables stale after create/transition/delete. - backend/app/services/missions.py: acquire the same per-scenario `pg_advisory_xact_lock` key used by `set_scenario_tests` before snapshotting; without it a concurrent M5 reorder could freeze a torn snapshot under READ COMMITTED. Sorted by key to avoid deadlocks with another snapshotter. Important: - backend/app/api/missions.py: `@require_perm("mission.update", "mission.archive")` on the transition endpoint so users without either perm get 403 before the body is parsed (no shape leak via 400). - backend/app/services/missions.py: escape `%` / `_` / `\` in user-typed `q` / `client` LIKE search; users can no longer trigger wildcard semantics by typing literal `%`. Added `escape='\\'` arg on every .like(). - backend/app/services/missions.py: filter `MissionTest.deleted_at` and `MissionScenario.deleted_at` in the list-item and detail counts so M7+ soft-deletes don't drift the totals silently. Nits: - backend/app/api/users.py: order `/users/roster` by email for stable rendering + deterministic e2e selectors. - frontend/src/pages/MissionDetailPage.tsx: distinct accent per transition target (cyan/orange/green/teal) matching the status legend. - e2e/tests/m6-missions.spec.ts: switch fragile `getByRole(name=/In Progress/i)` to the stable `mission-transition-in_progress` data-testid. New tests: - test_create_mission_rejects_soft_deleted_scenario - test_transition_perm_gate_runs_before_payload_parse - test_search_treats_wildcards_as_literals Suite: 106 pytest passing (was 103), 43 Playwright passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 — 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();
});
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
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();
});
});