Files
Metamorph/e2e/tests/m4-mitre.spec.ts

179 lines
7.8 KiB
TypeScript
Raw Normal View History

import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M4 MITRE ATT&CK Enterprise reference catalogue + tag picker.
*
* The seed itself (download + parse) is exercised by pytest with a small
* fixture bundle. This spec hits the live stack with the real, pinned bundle
* by calling `POST /mitre/sync` once and then validating the read endpoints
* + the picker UI.
*/
test(m4): cover the new security guards + pin e2e to exact MITRE v19 counts - 5 new pytest covering paths the code-reviewer flagged as un-asserted: * `test_seed_refuses_file_url` — `file://` scheme rejected before I/O (was the SSRF-to-local-FS vector). * `test_seed_refuses_disallowed_https_host` — non-allowlisted HTTPS host rejected with `MitreSourceForbidden`. * `test_seed_refuses_custom_url_without_sha` — end-to-end guard that `seed_mitre(source=<custom URL>, expected_sha256=None, allow_unverified=False)` raises `MitreSeedError`. * `test_dotted_id_fallback_resolves_orphan_subtechnique` — STIX bundle without `relationship[subtechnique-of]` still attaches T1059.001 to T1059 via the dotted-id convention. * `test_seed_clears_version_when_source_is_not_default` — seed from a local path leaves `settings.mitre_version` NULL (no stale pin). - Existing `test_checksum_mismatch_aborts` reworked to monkey-patch `_ensure_host_allowed` so `file://` can drive the test past the allowlist gate (was relying on file:// being accepted before CR1). - Removed unused `uuid` import. - e2e: assertions on `tactics_upserted`/`techniques_upserted`/ `subtechniques_upserted` switched from `>= 14/180/400` thresholds to `=== 15/222/475` exact counts pinned to MITRE Enterprise v19.0 + 0 orphans. Catches parser regressions that would silently include revoked rows. Bump alongside MITRE_VERSION when re-pinning. - e2e: `Math.random()` → `crypto.randomUUID().slice(0, 8)` for unique test-run emails (collision-safe across parallel CI workers). DoD: 58 pytest pass (was 53), 34 Playwright pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:34 +02:00
// crypto.randomUUID() guarantees uniqueness across parallel test runs; the
// Math.random() previous pattern could collide one-in-a-million in CI.
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('M4 — MITRE ATT&CK reference', () => {
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);
// Trigger a real sync against the pinned MITRE URL. Idempotent — if the
// mitre_* tables were left populated by a previous run, this is a no-op
// upsert.
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(), `mitre sync failed: ${await sync.text()}`).toBe(200);
const result = await sync.json();
test(m4): cover the new security guards + pin e2e to exact MITRE v19 counts - 5 new pytest covering paths the code-reviewer flagged as un-asserted: * `test_seed_refuses_file_url` — `file://` scheme rejected before I/O (was the SSRF-to-local-FS vector). * `test_seed_refuses_disallowed_https_host` — non-allowlisted HTTPS host rejected with `MitreSourceForbidden`. * `test_seed_refuses_custom_url_without_sha` — end-to-end guard that `seed_mitre(source=<custom URL>, expected_sha256=None, allow_unverified=False)` raises `MitreSeedError`. * `test_dotted_id_fallback_resolves_orphan_subtechnique` — STIX bundle without `relationship[subtechnique-of]` still attaches T1059.001 to T1059 via the dotted-id convention. * `test_seed_clears_version_when_source_is_not_default` — seed from a local path leaves `settings.mitre_version` NULL (no stale pin). - Existing `test_checksum_mismatch_aborts` reworked to monkey-patch `_ensure_host_allowed` so `file://` can drive the test past the allowlist gate (was relying on file:// being accepted before CR1). - Removed unused `uuid` import. - e2e: assertions on `tactics_upserted`/`techniques_upserted`/ `subtechniques_upserted` switched from `>= 14/180/400` thresholds to `=== 15/222/475` exact counts pinned to MITRE Enterprise v19.0 + 0 orphans. Catches parser regressions that would silently include revoked rows. Bump alongside MITRE_VERSION when re-pinning. - e2e: `Math.random()` → `crypto.randomUUID().slice(0, 8)` for unique test-run emails (collision-safe across parallel CI workers). DoD: 58 pytest pass (was 53), 34 Playwright pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:34 +02:00
// Pinned exactly to MITRE Enterprise v19.0 — bump alongside MITRE_VERSION
// in `app/services/mitre_seed.py` when the pin changes. Exact counts catch
// parser regressions that would silently include revoked/deprecated rows.
expect(result.tactics_upserted).toBe(15);
expect(result.techniques_upserted).toBe(222);
expect(result.subtechniques_upserted).toBe(475);
expect(result.subtechniques_skipped_orphan).toBe(0);
});
test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/tactics', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.total).toBeGreaterThanOrEqual(14);
const ids = body.items.map((t: { external_id: string }) => t.external_id);
expect(ids).toContain('TA0001'); // Initial Access
expect(ids).toContain('TA0006'); // Credential Access
});
test('GET /mitre/techniques?tactic=TA0006 filters', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/techniques?tactic=TA0006&limit=200', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.total).toBeGreaterThan(0);
// OS Credential Dumping is the textbook TA0006 example.
const ids = body.items.map((t: { external_id: string }) => t.external_id);
expect(ids).toContain('T1003');
});
test('GET /mitre/subtechniques?technique=T1003 lists 8 sub-techniques', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/subtechniques?technique=T1003', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const ids = (await r.json()).items.map((t: { external_id: string }) => t.external_id);
expect(ids).toContain('T1003.001'); // LSASS Memory
expect(ids.length).toBeGreaterThanOrEqual(5);
});
test('GET /mitre/status returns version + last_sync', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/status', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.last_sync).toBeTruthy();
expect(body.default_url).toContain('mitre-attack');
expect(body.default_version).toBeTruthy();
});
refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix The hierarchical 3-column drill-down was hard to scan and forced a stateful walk per tag. Replaced with a flat, columns-as-tactics matrix that mirrors attack.mitre.org/# — every cell is a one-click select target, with inline sub-technique expand via a `+N` chevron. - New endpoint GET /api/v1/mitre/matrix returns the full grid (tactics → techniques → sub-techniques nested) in a single ~55 KB response, so the SPA renders the whole matrix without firing 15 parallel queries. Two pytest tests added (nested structure + auth required). - MitreTagPicker.tsx rewritten as a horizontal-scrolling matrix: - Click a tactic header → select the tactic (cyan filled). - Click a technique cell → select the technique (orange filled). - Click the `+N` chevron → expand sub-techniques inline within the column. - Click a sub-technique → select (purple filled). - Single Filter field matches on external_id or name across all kinds. - Selection chips at the top, clickable to remove. - `aria-pressed` on every clickable cell for screen readers and Playwright. - e2e test updated to walk the new flow (click cell → assert aria-pressed, expand chevron, click sub, verify chip + JSON preview, filter to T1078). - Spec §F2 + §F12 + todo.md M4 entry updated to make the matrix layout the canonical UI for MITRE tagging (so future spec-reviewer passes accept it). - testing-m4.md walkthrough rewritten for the flat picker. DoD post-refactor: make test-api → 53 passed (was 51), make e2e → 34 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:32:20 +02:00
test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => {
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/mitre');
// Status card shows a non-null last_sync.
await expect(page.getByTestId('mitre-last-sync')).not.toHaveText('never');
const picker = page.getByTestId('mitre-tag-picker');
await expect(picker).toBeVisible();
refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix The hierarchical 3-column drill-down was hard to scan and forced a stateful walk per tag. Replaced with a flat, columns-as-tactics matrix that mirrors attack.mitre.org/# — every cell is a one-click select target, with inline sub-technique expand via a `+N` chevron. - New endpoint GET /api/v1/mitre/matrix returns the full grid (tactics → techniques → sub-techniques nested) in a single ~55 KB response, so the SPA renders the whole matrix without firing 15 parallel queries. Two pytest tests added (nested structure + auth required). - MitreTagPicker.tsx rewritten as a horizontal-scrolling matrix: - Click a tactic header → select the tactic (cyan filled). - Click a technique cell → select the technique (orange filled). - Click the `+N` chevron → expand sub-techniques inline within the column. - Click a sub-technique → select (purple filled). - Single Filter field matches on external_id or name across all kinds. - Selection chips at the top, clickable to remove. - `aria-pressed` on every clickable cell for screen readers and Playwright. - e2e test updated to walk the new flow (click cell → assert aria-pressed, expand chevron, click sub, verify chip + JSON preview, filter to T1078). - Spec §F2 + §F12 + todo.md M4 entry updated to make the matrix layout the canonical UI for MITRE tagging (so future spec-reviewer passes accept it). - testing-m4.md walkthrough rewritten for the flat picker. DoD post-refactor: make test-api → 53 passed (was 51), make e2e → 34 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:32:20 +02:00
// The matrix has a column per tactic.
await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible();
// 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected.
const t1003 = picker.getByTestId('mitre-technique-T1003').first();
await t1003.scrollIntoViewIfNeeded();
await t1003.click();
await expect(page.getByTestId('mitre-selected')).toContainText('T1003');
await expect(t1003).toHaveAttribute('aria-pressed', 'true');
// 2. Expand T1003's sub-techniques inline via the +N chevron.
await picker.getByTestId('mitre-expand-T1003').first().click();
const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first();
await expect(sub).toBeVisible();
// 3. Click the sub-technique → chip + JSON preview both update.
await sub.click();
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix The hierarchical 3-column drill-down was hard to scan and forced a stateful walk per tag. Replaced with a flat, columns-as-tactics matrix that mirrors attack.mitre.org/# — every cell is a one-click select target, with inline sub-technique expand via a `+N` chevron. - New endpoint GET /api/v1/mitre/matrix returns the full grid (tactics → techniques → sub-techniques nested) in a single ~55 KB response, so the SPA renders the whole matrix without firing 15 parallel queries. Two pytest tests added (nested structure + auth required). - MitreTagPicker.tsx rewritten as a horizontal-scrolling matrix: - Click a tactic header → select the tactic (cyan filled). - Click a technique cell → select the technique (orange filled). - Click the `+N` chevron → expand sub-techniques inline within the column. - Click a sub-technique → select (purple filled). - Single Filter field matches on external_id or name across all kinds. - Selection chips at the top, clickable to remove. - `aria-pressed` on every clickable cell for screen readers and Playwright. - e2e test updated to walk the new flow (click cell → assert aria-pressed, expand chevron, click sub, verify chip + JSON preview, filter to T1078). - Spec §F2 + §F12 + todo.md M4 entry updated to make the matrix layout the canonical UI for MITRE tagging (so future spec-reviewer passes accept it). - testing-m4.md walkthrough rewritten for the flat picker. DoD post-refactor: make test-api → 53 passed (was 51), make e2e → 34 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:32:20 +02:00
// 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible.
await picker.getByLabel(/^filter$/i).fill('valid');
await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible();
});
test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => {
// Invite a no-perm user via the admin.
const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
test(m4): cover the new security guards + pin e2e to exact MITRE v19 counts - 5 new pytest covering paths the code-reviewer flagged as un-asserted: * `test_seed_refuses_file_url` — `file://` scheme rejected before I/O (was the SSRF-to-local-FS vector). * `test_seed_refuses_disallowed_https_host` — non-allowlisted HTTPS host rejected with `MitreSourceForbidden`. * `test_seed_refuses_custom_url_without_sha` — end-to-end guard that `seed_mitre(source=<custom URL>, expected_sha256=None, allow_unverified=False)` raises `MitreSeedError`. * `test_dotted_id_fallback_resolves_orphan_subtechnique` — STIX bundle without `relationship[subtechnique-of]` still attaches T1059.001 to T1059 via the dotted-id convention. * `test_seed_clears_version_when_source_is_not_default` — seed from a local path leaves `settings.mitre_version` NULL (no stale pin). - Existing `test_checksum_mismatch_aborts` reworked to monkey-patch `_ensure_host_allowed` so `file://` can drive the test past the allowlist gate (was relying on file:// being accepted before CR1). - Removed unused `uuid` import. - e2e: assertions on `tactics_upserted`/`techniques_upserted`/ `subtechniques_upserted` switched from `>= 14/180/400` thresholds to `=== 15/222/475` exact counts pinned to MITRE Enterprise v19.0 + 0 orphans. Catches parser regressions that would silently include revoked rows. Bump alongside MITRE_VERSION when re-pinning. - e2e: `Math.random()` → `crypto.randomUUID().slice(0, 8)` for unique test-run emails (collision-safe across parallel CI workers). DoD: 58 pytest pass (was 53), 34 Playwright pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:34 +02:00
const eveEmail = `eve-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const inv = await request.post('/api/v1/invitations', {
headers: { Authorization: `Bearer ${adminAccess}` },
data: { email_hint: eveEmail },
});
const token = (await inv.json()).token;
await request.post(`/api/v1/invitations/accept/${token}`, {
data: { email: eveEmail, password: 'EvePass1234!' },
});
const eveAccess = await loginAndGetAccess(request, eveEmail, 'EvePass1234!');
const r = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${eveAccess}` },
});
expect(r.status()).toBe(403);
// The MITRE page is reachable in read-only mode for any logged-in user,
// but the Sync card is hidden for non-admins.
await loginViaSpa(page, eveEmail, 'EvePass1234!');
await page.goto('/mitre');
await expect(page.getByTestId('mitre-tag-picker')).toBeVisible();
await expect(page.getByTestId('mitre-sync')).toHaveCount(0);
});
});