440 lines
15 KiB
TypeScript
440 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(/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();
|
||
|
|
});
|
||
|
|
});
|