feat(m7): per-test execution — red/blue zones, evidence pipeline, activity poll
DoD M7 (spec §F5 + §F6 + §F8 + tasks/todo.md M7) covered end-to-end:
Backend
- New migration `91a4e7c6d2f3` adds `mission_tests.last_actor_id` (FK users
ON DELETE SET NULL) and `ix_mission_tests_updated_at` for the polling query.
- `detection_levels`: 4 default rows seeded at boot, `GET /detection-levels`
read-only (CRUD lands in M8).
- `mission_tests` service + `missions` API extension:
- `GET /missions/{id}/tests/{test_id}` — full detail incl. evidence list
- `PUT /missions/{id}/tests/{test_id}` — patch red/blue fields with per-field
perm classification (`mission.write_red_fields` vs `mission.write_blue_fields`)
- `POST /missions/{id}/tests/{test_id}/transition` — pending↔skipped/blocked
and pending→executed→reviewed_by_blue (+ undo paths), side-aware perm gate
that fires *before* idempotency, `executed_at` auto-stamped on the way in
- `GET /missions/{id}/activity?since=<ISO>` — drives the 15 s polling badge
- `evidence` service + top-level `/evidence/<id>` API:
- Streaming upload, SHA256 chunk-by-chunk, 25 MB cap, ext+MIME whitelist
- Content-addressed storage at ${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>
- Atomic `os.replace`, hex-validated SHA path component, root-dir guard
- Membership-aware (404 on miss/forbidden, no existence leak)
- `/diag/reset` now wipes ${EVIDENCE_DIR}/* in test mode (symlink-safe) and
re-seeds detection levels as a safety net.
Frontend
- `lib/missions.ts` — M7 types + queryKey factory + state-machine matrix.
- `pages/MissionTestPage.tsx` — two-zone layout: red border (command, output,
comment, mark-executed + override toggle) and cyan border (detection-level
select, comment, drag-and-drop evidence dropzone). Last-touched badge polls
/activity every 15 s, gated on document.visibilityState. Per-field disable
based on the user's red/blue perms (server stays the arbiter).
- `pages/MissionDetailPage.tsx` — test rows link to the new per-test page.
- `App.tsx` — registers /missions/:id/tests/:testId behind RequireAuth.
- `HomePage.tsx` — hero + roadmap card bumped to M7; next is M8.
Tests
- `backend/tests/test_mission_tests.py` — 27 pytest tests (red/blue field
gating, state-machine matrix incl. idempotent-side enforcement, executed_at
override, 24/26 MB upload + SHA256, MIME/ext whitelist, soft-delete hide,
activity polling with URL-encoded `since`, membership 404 vs admin bypass,
cross-mission evidence access).
- `e2e/tests/m7-execution.spec.ts` — 5 Playwright tests against the live stack
(red-only/blue-only API gating, mark-executed + reviewed_by_blue side
enforcement, 24 MB/26 MB upload + SHA256 round-trip, SPA per-test page save
+ transition, non-member 404 message). afterAll restores stable admin and
re-syncs MITRE.
Docs
- CHANGELOG.md: M7 section + post-M7 review-pass subsection.
- README.md: status, feature blurb, roadmap, testing-m7 link.
- tasks/testing-m7.md: manual + automated procedure with transition matrix
and perm-gating table.
- tasks/lessons.md: M7 retrospectives (LogRecord `created` trap, URL-encoded
query timestamps, perm-before-flush, atomic move, polling visibility gate).
Test count: 133 pytest / 49 Playwright, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
439
e2e/tests/m7-execution.spec.ts
Normal file
439
e2e/tests/m7-execution.spec.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M7 — Red/blue execution on a mission test.
|
||||
*
|
||||
* Scope (cf. tasks/spec.md §M7):
|
||||
* - Field-level perm gating (write_red_fields vs write_blue_fields).
|
||||
* - State machine transitions and side-effect on `executed_at`.
|
||||
* - Evidence upload: 24 MB ok, 26 MB rejected, SHA256 verified.
|
||||
* - Activity polling endpoint surfaces the last actor.
|
||||
* - SPA: the per-test page exposes both zones, accepts a small file via the
|
||||
* dropzone, and shows the "modified by X" badge after a write.
|
||||
*
|
||||
* `afterAll` restores `admin@metamorph.local` / `AdminPass1234!` and re-syncs
|
||||
* MITRE so subsequent manual sessions are not staring at an empty stack.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `m7-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 login(
|
||||
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 inviteUser(
|
||||
request: APIRequestContext,
|
||||
adminAuth: Record<string, string>,
|
||||
prefix: string,
|
||||
groupCodes: string[],
|
||||
): Promise<{ email: string; password: string; token: string; id: string }> {
|
||||
const grp = await request.post('/api/v1/groups', {
|
||||
headers: adminAuth,
|
||||
data: { name: `${prefix}-${crypto.randomUUID().slice(0, 4)}` },
|
||||
});
|
||||
expect(grp.status()).toBe(201);
|
||||
const grpId = (await grp.json()).id as string;
|
||||
const setPerms = await request.put(`/api/v1/groups/${grpId}/permissions`, {
|
||||
headers: adminAuth,
|
||||
data: { codes: groupCodes },
|
||||
});
|
||||
expect(setPerms.status()).toBe(200);
|
||||
const email = `${prefix}-${crypto.randomUUID().slice(0, 6)}@metamorph.local`;
|
||||
const inv = await request.post('/api/v1/invitations', {
|
||||
headers: adminAuth,
|
||||
data: { email_hint: email, group_ids: [grpId] },
|
||||
});
|
||||
expect(inv.status()).toBe(201);
|
||||
const inviteToken = (await inv.json()).token as string;
|
||||
const password = 'Pass1234!';
|
||||
const accept = await request.post(
|
||||
`/api/v1/invitations/accept/${inviteToken}`,
|
||||
{ data: { email, password } },
|
||||
);
|
||||
expect(accept.status()).toBe(201);
|
||||
const token = await login(request, email, password);
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(me.status()).toBe(200);
|
||||
return { email, password, token, id: (await me.json()).id as string };
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M7 — Test execution', () => {
|
||||
let templateId = '';
|
||||
let scenarioId = '';
|
||||
|
||||
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 login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const sync = await request.post('/api/v1/mitre/sync', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(sync.status()).toBe(200);
|
||||
const auth = { Authorization: `Bearer ${access}` };
|
||||
|
||||
const t = await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'm7-test',
|
||||
description: 'auto',
|
||||
objective: 'do thing',
|
||||
procedure_md: '# steps',
|
||||
expected_result_red_md: 'red expects',
|
||||
expected_detection_blue_md: 'blue expects',
|
||||
opsec_level: 'medium',
|
||||
tags: [],
|
||||
expected_iocs: [],
|
||||
mitre_tags: [{ kind: 'technique', external_id: 'T1059' }],
|
||||
},
|
||||
});
|
||||
expect(t.status()).toBe(201);
|
||||
templateId = (await t.json()).id as string;
|
||||
|
||||
const sc = await request.post('/api/v1/scenario-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'm7-scenario',
|
||||
description: 'auto',
|
||||
test_template_ids: [templateId],
|
||||
},
|
||||
});
|
||||
expect(sc.status()).toBe(201);
|
||||
scenarioId = (await sc.json()).id as string;
|
||||
});
|
||||
|
||||
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 login(
|
||||
request,
|
||||
'admin@metamorph.local',
|
||||
'AdminPass1234!',
|
||||
);
|
||||
await request.post('/api/v1/mitre/sync', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// API — field-level perm gating + state machine
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('red-only user cannot write blue fields; blue-only user cannot write red', async ({
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
|
||||
const red = await inviteUser(request, adminAuth, 'red', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.write_red_fields',
|
||||
'detection_level.read',
|
||||
]);
|
||||
const blue = await inviteUser(request, adminAuth, 'blue', [
|
||||
'mission.read',
|
||||
'mission.write_blue_fields',
|
||||
'detection_level.read',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: {
|
||||
name: 'm7-fields',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [
|
||||
{ user_id: red.id, role_hint: 'red' },
|
||||
{ user_id: blue.id, role_hint: 'blue' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mission.status()).toBe(201);
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
const redCannotBlue = await request.put(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: { blue_comment_md: 'should be blocked' },
|
||||
},
|
||||
);
|
||||
expect(redCannotBlue.status()).toBe(403);
|
||||
|
||||
const blueCannotRed = await request.put(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
data: { red_command: 'should be blocked' },
|
||||
},
|
||||
);
|
||||
expect(blueCannotRed.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('mark-executed stamps executed_at and gates reviewed_by_blue to blue side', async ({
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const red = await inviteUser(request, adminAuth, 'red', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.write_red_fields',
|
||||
]);
|
||||
const blue = await inviteUser(request, adminAuth, 'blue', [
|
||||
'mission.read',
|
||||
'mission.write_blue_fields',
|
||||
]);
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: {
|
||||
name: 'm7-state',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [
|
||||
{ user_id: red.id, role_hint: 'red' },
|
||||
{ user_id: blue.id, role_hint: 'blue' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
const execute = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: { target_state: 'executed' },
|
||||
},
|
||||
);
|
||||
expect(execute.status()).toBe(200);
|
||||
const executedBody = await execute.json();
|
||||
expect(executedBody.state).toBe('executed');
|
||||
expect(executedBody.executed_at).not.toBeNull();
|
||||
|
||||
// Red cannot review_by_blue.
|
||||
const redReview = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: { target_state: 'reviewed_by_blue' },
|
||||
},
|
||||
);
|
||||
expect(redReview.status()).toBe(403);
|
||||
|
||||
const blueReview = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
data: { target_state: 'reviewed_by_blue' },
|
||||
},
|
||||
);
|
||||
expect(blueReview.status()).toBe(200);
|
||||
expect((await blueReview.json()).state).toBe('reviewed_by_blue');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// API — evidence upload
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('evidence upload — 24 MB accepted, 26 MB rejected, SHA256 verified', async ({
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const blue = await inviteUser(request, adminAuth, 'blue', [
|
||||
'mission.read',
|
||||
'mission.write_blue_fields',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: adminAuth,
|
||||
data: {
|
||||
name: 'm7-evidence',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [{ user_id: blue.id, role_hint: 'blue' }],
|
||||
},
|
||||
});
|
||||
expect(mission.status()).toBe(201);
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
const headerOk = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const tail24 = Buffer.alloc(24 * 1024 * 1024 - headerOk.length, 0x41);
|
||||
const file24 = Buffer.concat([headerOk, tail24]);
|
||||
const expected = await crypto.subtle.digest('SHA-256', file24);
|
||||
const expectedHex = Array.from(new Uint8Array(expected))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const ok = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/evidence`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'lab.evtx',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: file24,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(ok.status()).toBe(201);
|
||||
const body = await ok.json();
|
||||
expect(body.size_bytes).toBe(file24.length);
|
||||
expect(body.sha256).toBe(expectedHex);
|
||||
|
||||
const file26 = Buffer.concat([
|
||||
headerOk,
|
||||
Buffer.alloc(26 * 1024 * 1024 - headerOk.length, 0x41),
|
||||
]);
|
||||
const tooBig = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/evidence`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'huge.evtx',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: file26,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(tooBig.status()).toBe(400);
|
||||
expect((await tooBig.json()).error).toBe('too_large');
|
||||
|
||||
const evictGet = await request.get(
|
||||
`/api/v1/evidence/${body.id}?download=true`,
|
||||
{ headers: { Authorization: `Bearer ${blue.token}` } },
|
||||
);
|
||||
expect(evictGet.status()).toBe(200);
|
||||
const dlBytes = await evictGet.body();
|
||||
expect(dlBytes.length).toBe(file24.length);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SPA — per-test page edits & uploads
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('SPA — per-test page: red comment save bumps activity badge', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const red = await inviteUser(request, adminAuth, 'red', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.update',
|
||||
'mission.write_red_fields',
|
||||
'detection_level.read',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: {
|
||||
name: 'm7-spa',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [{ user_id: red.id, role_hint: 'red' }],
|
||||
},
|
||||
});
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
// Log the SPA in as the red user.
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(red.email);
|
||||
await page.getByLabel(/password/i).fill(red.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(red.email);
|
||||
|
||||
await page.goto(`/missions/${m.id}/tests/${testId}`);
|
||||
await expect(page.getByTestId('mission-test-page')).toBeVisible();
|
||||
await expect(page.getByTestId('state-pill')).toContainText(/Pending/);
|
||||
|
||||
// Fill the red command + comment, then save.
|
||||
await page.getByTestId('red-command').fill('whoami /priv');
|
||||
await page.getByTestId('red-comment').fill('Verified locally');
|
||||
await page.getByTestId('red-save').click();
|
||||
|
||||
// After save the state-pill stays Pending (only transitions change it).
|
||||
await expect(page.getByTestId('state-pill')).toContainText(/Pending/);
|
||||
|
||||
// Now transition to executed via the header button.
|
||||
await page.getByTestId('transition-executed').click();
|
||||
await expect(page.getByTestId('state-pill')).toContainText(/Executed/);
|
||||
|
||||
// The "last touched" line should now mention the red user.
|
||||
await expect(page.locator('text=/Last touched/')).toBeVisible();
|
||||
});
|
||||
|
||||
test('SPA — non-member sees 404 message instead of mission content', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const owner = await inviteUser(request, adminAuth, 'own', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.write_red_fields',
|
||||
]);
|
||||
const outsider = await inviteUser(request, adminAuth, 'out', [
|
||||
'mission.read',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${owner.token}` },
|
||||
data: {
|
||||
name: 'm7-private',
|
||||
scenario_template_ids: [scenarioId],
|
||||
},
|
||||
});
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(outsider.email);
|
||||
await page.getByLabel(/password/i).fill(outsider.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(outsider.email);
|
||||
|
||||
await page.goto(`/missions/${m.id}/tests/${testId}`);
|
||||
await expect(
|
||||
page.locator('text=/Mission test not found/'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user