Files
Metamorph/e2e/tests/m7-execution.spec.ts
Knacky 28b8855e88 feat(m7-amend2): implicit lifecycle — writes drive state, no workflow UI
User: «Enlève également le workflow d'un test, quand on saisit des
informations côtés redteam cela signifie qu'il a été exécuté et donc
en attente d'une review blueteam.»

Backend (update_mission_test_fields)
- At the end of every PUT, inspect the touched-field set:
  - any red write on state in {pending, skipped, blocked} → state=executed
    + auto-stamp executed_at=now() if absent
  - any blue write on state=executed → state=reviewed_by_blue
- /transition endpoint kept for back-fill/admin use, not called from UI.

Frontend MissionTestPage
- Removed the transition-buttons header block and the `transition`
  mutation. State pill stays as a passive indicator.
- New labels: "Not started" / "Awaiting review" / "Reviewed" describe
  the implicit lifecycle, no longer exposing the state-machine concept.

E2E
- The SPA test that clicked `transition-executed` now verifies the
  implicit promotion: typing red fields and saving flips the pill from
  "Not started" → "Awaiting review", no button click required.

Spec
- §4 reword: "Cycle de vie implicite, piloté par les écritures" replaces
  the old "Workflow par test instance" bullet.

Tests
- 3 new pytest: red_command-alone implicit execute + auto-stamp,
  blue write promotes executed→reviewed, blue write on pending no-op.
- 142 pytest + 49 Playwright green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:09:26 +02:00

437 lines
15 KiB
TypeScript

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(/Not started/);
// Fill the red command + comment, then save. Post-amendement 2026-05-15
// bis: writing red fields implicitly promotes the state — no transition
// button click required.
await page.getByTestId('red-command').fill('whoami /priv');
await page.getByTestId('red-comment').fill('Verified locally');
await page.getByTestId('red-save').click();
await expect(page.getByTestId('state-pill')).toContainText(/Awaiting review/);
// 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();
});
});