test(m5): playwright spec + docs (CHANGELOG, README, lessons, testing-m5)
- 4 Playwright tests: API CRUD round-trip, scenario reorder via PUT, SPA list + opsec filter, SPA scenario list rendering with ordered tests. - afterAll restores the stable admin (admin@metamorph.local) per the test_admin memory rule. - CHANGELOG M5 section + Fixed subsections for the LogRecord 'name' collision and the React `currentTarget` vs `target` quirk. - README status bumps to M0-M5. - tasks/lessons.md captures the new patterns (sentinel pattern for partial-update, FK ordering in /diag/reset, dnd-kit stable IDs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
253
e2e/tests/m5-templates.spec.ts
Normal file
253
e2e/tests/m5-templates.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M5 — Test + Scenario template catalogue.
|
||||
*
|
||||
* Verifies CRUD on /test-templates and /scenario-templates plus the admin SPA
|
||||
* pages. We do NOT seed the full MITRE bundle here — M4 already covers that
|
||||
* suite. This spec only needs ONE technique resolvable from a STIX-like
|
||||
* shape (we ride on the same `/diag/reset` then re-seed MITRE so tag refs
|
||||
* resolve).
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `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('M5 — Template catalogue', () => {
|
||||
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);
|
||||
// MITRE re-sync — picker + tag refs rely on the canonical bundle.
|
||||
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 }) => {
|
||||
// Restore the stable admin (cf. memory feedback_metamorph_test_admin):
|
||||
// any wipe should leave admin@metamorph.local / AdminPass1234! usable.
|
||||
const installToken = await resetAndMintToken(request);
|
||||
await request.post('/api/v1/setup', {
|
||||
data: {
|
||||
install_token: installToken,
|
||||
email: 'admin@metamorph.local',
|
||||
password: 'AdminPass1234!',
|
||||
},
|
||||
});
|
||||
// Re-seed MITRE so subsequent manual sessions don't see an empty matrix.
|
||||
const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!');
|
||||
await request.post('/api/v1/mitre/sync', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
});
|
||||
|
||||
// === API smoke ============================================================
|
||||
|
||||
test('CRUD test-templates via API', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const auth = { Authorization: `Bearer ${access}` };
|
||||
|
||||
// Create
|
||||
const r1 = await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'phish-link',
|
||||
description: 'send a phishing email with tracked link',
|
||||
objective: 'land a click',
|
||||
procedure_md: '1. craft mail\n2. send\n3. await click',
|
||||
opsec_level: 'low',
|
||||
tags: ['phish', 'initial-access'],
|
||||
expected_iocs: ['phish@example.com'],
|
||||
mitre_tags: [
|
||||
{ kind: 'tactic', external_id: 'TA0001' },
|
||||
{ kind: 'technique', external_id: 'T1566' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r1.status(), await r1.text()).toBe(201);
|
||||
const created = await r1.json();
|
||||
expect(created.name).toBe('phish-link');
|
||||
expect(created.mitre_tags.length).toBe(2);
|
||||
expect(created.tags).toContain('phish');
|
||||
|
||||
// Update — partial: change opsec only
|
||||
const r2 = await request.put(`/api/v1/test-templates/${created.id}`, {
|
||||
headers: auth,
|
||||
data: { opsec_level: 'high' },
|
||||
});
|
||||
expect(r2.status()).toBe(200);
|
||||
const updated = await r2.json();
|
||||
expect(updated.opsec_level).toBe('high');
|
||||
expect(updated.name).toBe('phish-link'); // untouched
|
||||
|
||||
// List + filter by tactic
|
||||
const r3 = await request.get('/api/v1/test-templates?tactic=TA0001', {
|
||||
headers: auth,
|
||||
});
|
||||
expect(r3.status()).toBe(200);
|
||||
const list = await r3.json();
|
||||
expect(list.items.map((it: { name: string }) => it.name)).toContain('phish-link');
|
||||
|
||||
// Reject unknown MITRE
|
||||
const r4 = await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'bad',
|
||||
mitre_tags: [{ kind: 'technique', external_id: 'T9999' }],
|
||||
},
|
||||
});
|
||||
expect(r4.status()).toBe(400);
|
||||
expect((await r4.json()).error).toBe('unknown_mitre_tag');
|
||||
|
||||
// Soft-delete
|
||||
const r5 = await request.delete(`/api/v1/test-templates/${created.id}`, {
|
||||
headers: auth,
|
||||
});
|
||||
expect(r5.status()).toBe(200);
|
||||
const r6 = await request.get('/api/v1/test-templates', { headers: auth });
|
||||
expect(
|
||||
(await r6.json()).items.map((it: { name: string }) => it.name),
|
||||
).not.toContain('phish-link');
|
||||
});
|
||||
|
||||
test('Scenario template: create + reorder + soft-delete', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const auth = { Authorization: `Bearer ${access}` };
|
||||
|
||||
async function mkTest(name: string): Promise<string> {
|
||||
const r = await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: { name },
|
||||
});
|
||||
expect(r.status()).toBe(201);
|
||||
return (await r.json()).id as string;
|
||||
}
|
||||
|
||||
const a = await mkTest('scn-step-a');
|
||||
const b = await mkTest('scn-step-b');
|
||||
const c = await mkTest('scn-step-c');
|
||||
|
||||
// Create with [a, b, c]
|
||||
const r1 = await request.post('/api/v1/scenario-templates', {
|
||||
headers: auth,
|
||||
data: { name: 'ordered-scenario', test_template_ids: [a, b, c] },
|
||||
});
|
||||
expect(r1.status()).toBe(201);
|
||||
const sc = await r1.json();
|
||||
expect(sc.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
|
||||
'scn-step-a',
|
||||
'scn-step-b',
|
||||
'scn-step-c',
|
||||
]);
|
||||
|
||||
// Reorder → [c, a, b]
|
||||
const r2 = await request.put(`/api/v1/scenario-templates/${sc.id}/tests`, {
|
||||
headers: auth,
|
||||
data: { test_template_ids: [c, a, b] },
|
||||
});
|
||||
expect(r2.status()).toBe(200);
|
||||
const after = await r2.json();
|
||||
expect(after.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
|
||||
'scn-step-c',
|
||||
'scn-step-a',
|
||||
'scn-step-b',
|
||||
]);
|
||||
|
||||
// Soft-delete the scenario.
|
||||
const r3 = await request.delete(`/api/v1/scenario-templates/${sc.id}`, { headers: auth });
|
||||
expect(r3.status()).toBe(200);
|
||||
const list = await (await request.get('/api/v1/scenario-templates', { headers: auth })).json();
|
||||
expect(list.items.map((it: { name: string }) => it.name)).not.toContain('ordered-scenario');
|
||||
});
|
||||
|
||||
// === SPA smoke ============================================================
|
||||
|
||||
test('SPA — admin sees the test catalogue and can filter', async ({ page, request }) => {
|
||||
// Seed two tests up front via the API — exercise the SPA list + filter
|
||||
// pipeline without fighting the heavy create-modal (covered by API tests).
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const auth = { Authorization: `Bearer ${access}` };
|
||||
await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: { name: 'spa-list-fast', opsec_level: 'low', tags: ['fast'] },
|
||||
});
|
||||
await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: { name: 'spa-list-slow', opsec_level: 'high' },
|
||||
});
|
||||
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto('/admin/tests');
|
||||
|
||||
await expect(page.getByText('spa-list-fast')).toBeVisible();
|
||||
await expect(page.getByText('spa-list-slow')).toBeVisible();
|
||||
|
||||
await page.getByTestId('filter-opsec').selectOption('high');
|
||||
await expect(page.getByText('spa-list-slow')).toBeVisible();
|
||||
await expect(page.getByText('spa-list-fast')).toBeHidden();
|
||||
});
|
||||
|
||||
test('SPA — scenario list shows ordered tests with their position', async ({ page, request }) => {
|
||||
// Seed a 3-test scenario via the API; the SPA must render the order as
|
||||
// saved. Pointer-event drag is flaky in CI, and the API-level reorder
|
||||
// test already covers the persistence pipeline.
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const auth = { Authorization: `Bearer ${access}` };
|
||||
const ids: string[] = [];
|
||||
for (const name of ['drag-1', 'drag-2', 'drag-3']) {
|
||||
const r = await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: { name },
|
||||
});
|
||||
ids.push((await r.json()).id);
|
||||
}
|
||||
const scResp = await request.post('/api/v1/scenario-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'spa-rendered-scenario',
|
||||
test_template_ids: [ids[2], ids[0], ids[1]],
|
||||
},
|
||||
});
|
||||
const scId = (await scResp.json()).id;
|
||||
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto('/admin/scenarios');
|
||||
|
||||
const card = page.locator(`[data-testid="scenario-row-${scId}"]`);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText('1. drag-3')).toBeVisible();
|
||||
await expect(card.getByText('2. drag-1')).toBeVisible();
|
||||
await expect(card.getByText('3. drag-2')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user