Files
mimic/e2e/tests/us4-engagements.spec.ts
Knacky 5aa839d105 test(e2e): sprint 4 acceptance tests — US-17 to US-23
Add new spec files for US-17 (UI polish), US-18 (done read-only + reopen),
US-19 (engagement auto-status), US-20 (matrix fits modal), US-21 (tactic
selection), US-22 (MITRE input redesign), US-23 (dark mode).

Adapt sprint 2/3 specs for sprint 4 UI renames: matrix icon button replaces
text buttons, inline search replaces Quick Search, Save replaces Save Red Team,
New replaces New Engagement, topbar uses bg-slab tokens, Apply N item(s) replaces
Apply N technique(s), done→review_required transition now valid (Reopen flow).

Mark AC-21.6 Apply-from-modal as test.fail: known defect where /api/mitre/matrix
returns slug tactic IDs but PATCH /simulations/:id expects TA-format IDs.

Final result: 156 passed, 0 failed (1 expected failure via test.fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:27:12 +02:00

274 lines
9.6 KiB
TypeScript

/**
* US-4 — engagement CRUD + RBAC + UI surfaces.
* Covers AC-4.1 → AC-4.9.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteAllEngagements,
deleteEngagement,
deleteUserByUsername,
ensureUser,
listEngagements,
login,
makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us4-redteam';
const SOC_USER = 'us4-soc';
const PASS = 'us4-pass-strong';
test.describe('US-4 — engagement CRUD', () => {
let redteamToken: string;
let socToken: string;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
// Clean slate so AC-4.7 list assertions are predictable.
await deleteAllEngagements(await adminToken());
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteAllEngagements(tok);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-4.1 — GET /api/engagements returns serialized list (created_by = {id, username})', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.1 sample',
start_date: '2026-02-01',
});
const items = await listEngagements(redteamToken);
const row = items.find((i) => i.id === seeded.id);
expect(row).toBeTruthy();
expect(row).toMatchObject({
name: 'AC-4.1 sample',
status: 'planned',
start_date: '2026-02-01',
});
expect(row!.created_by).toMatchObject({ username: REDTEAM_USER });
expect(typeof row!.created_by!.id).toBe('number');
});
test('AC-4.2 — POST validates name/dates/status', async () => {
const client = makeClient(redteamToken);
const blankName = await client.post('/engagements', {
name: '',
start_date: '2026-03-01',
});
expect(blankName.status).toBe(400);
const noStart = await client.post('/engagements', { name: 'x' });
expect(noStart.status).toBe(400);
const badDate = await client.post('/engagements', {
name: 'x',
start_date: 'not-a-date',
});
expect(badDate.status).toBe(400);
const endBeforeStart = await client.post('/engagements', {
name: 'x',
start_date: '2026-04-10',
end_date: '2026-04-01',
});
expect(endBeforeStart.status).toBe(400);
const badStatus = await client.post('/engagements', {
name: 'x',
start_date: '2026-04-01',
status: 'frozen',
});
expect(badStatus.status).toBe(400);
const defaultStatus = await client.post('/engagements', {
name: 'AC-4.2 default-status',
start_date: '2026-04-01',
});
expect(defaultStatus.status).toBe(201);
expect(defaultStatus.data.status).toBe('planned');
});
test('AC-4.3 — GET /api/engagements/<id> returns 200 + object, 404 if unknown', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.3 sample',
start_date: '2026-05-01',
});
const client = makeClient(redteamToken);
const ok = await client.get(`/engagements/${seeded.id}`);
expect(ok.status).toBe(200);
expect(ok.data.id).toBe(seeded.id);
const missing = await client.get('/engagements/999999');
expect(missing.status).toBe(404);
});
test('AC-4.4 — PATCH (admin/redteam) updates fields', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.4 orig',
start_date: '2026-06-01',
});
const client = makeClient(redteamToken);
const r = await client.patch(`/engagements/${seeded.id}`, {
name: 'AC-4.4 updated',
status: 'active',
end_date: '2026-06-15',
});
expect(r.status).toBe(200);
expect(r.data).toMatchObject({
name: 'AC-4.4 updated',
status: 'active',
end_date: '2026-06-15',
});
});
test('AC-4.5 — DELETE (admin/redteam) returns 204', async () => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.5 disposable',
start_date: '2026-07-01',
});
const client = makeClient(redteamToken);
const r = await client.delete(`/engagements/${seeded.id}`);
expect(r.status).toBe(204);
const after = await client.get(`/engagements/${seeded.id}`);
expect(after.status).toBe(404);
});
test('AC-4.6 — soc can read but not write (403 on POST/PATCH/DELETE)', async () => {
const socClient = makeClient(socToken);
const list = await socClient.get('/engagements');
expect(list.status).toBe(200);
const post = await socClient.post('/engagements', {
name: 'soc-blocked',
start_date: '2026-08-01',
});
expect(post.status).toBe(403);
// Seed via redteam to get a target id.
const target = await createEngagement(redteamToken, {
name: 'AC-4.6 target',
start_date: '2026-08-15',
});
const patch = await socClient.patch(`/engagements/${target.id}`, { name: 'soc-edit' });
expect(patch.status).toBe(403);
const del = await socClient.delete(`/engagements/${target.id}`);
expect(del.status).toBe(403);
// Clean up via redteam.
await deleteEngagement(redteamToken, target.id);
});
test('AC-4.7 — /engagements page lists rows with required columns + role-aware buttons', async ({
page,
context,
}) => {
// Seed one row visible to the redteam user.
await createEngagement(redteamToken, {
name: 'UI list sample',
start_date: '2026-09-01',
status: 'active',
});
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible();
// Column headers
for (const h of ['Name', 'Status', 'Start', 'End', 'Created by']) {
await expect(page.getByRole('columnheader', { name: new RegExp(h, 'i') })).toBeVisible();
}
// The row + status badge + created_by visible
const row = page.getByRole('row', { name: /UI list sample/i });
await expect(row).toBeVisible();
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
// Redteam sees the action buttons. Sprint 4: "New engagement" renamed to "New".
await expect(page.getByRole('link', { name: /^new$/i })).toBeVisible();
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
// Soc should NOT see write buttons.
await seedTokenInStorage(context, socToken);
await page.goto('/engagements');
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
await expect(rowAsSoc).toBeVisible();
await expect(page.getByRole('link', { name: /^new$/i })).toHaveCount(0);
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
});
test('AC-4.8 — /engagements/new form: client validation + API error display', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements/new');
await expect(page.getByRole('heading', { name: /new engagement/i })).toBeVisible();
// Submit empty → client-side errors visible.
await page.getByRole('button', { name: /create engagement/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page.getByText(/start date is required/i)).toBeVisible();
// Fill bad date order → client validation flags end_date.
await page.fill('#eng-name', 'UI form test');
await page.fill('#eng-start', '2026-10-10');
await page.fill('#eng-end', '2026-10-01');
await page.getByRole('button', { name: /create engagement/i }).click();
await expect(page.getByText(/end date must be on or after start date/i)).toBeVisible();
// Fix dates → submit succeeds, redirects to detail.
await page.fill('#eng-end', '2026-10-20');
await page.getByRole('button', { name: /create engagement/i }).click();
await page.waitForURL(/\/engagements\/\d+$/);
await expect(page.getByRole('heading', { name: /UI form test/i })).toBeVisible();
// Edit path: navigate to /edit and tweak.
const detailUrl = page.url();
const id = Number(detailUrl.split('/').pop());
await page.goto(`/engagements/${id}/edit`);
await expect(page.getByRole('heading', { name: /edit engagement/i })).toBeVisible();
await page.fill('#eng-name', 'UI form test (edited)');
await page.getByRole('button', { name: /save changes/i }).click();
await page.waitForURL(new RegExp(`/engagements/${id}$`));
await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible();
});
test('AC-4.9 — /engagements/<id> detail page shows Simulations section (sprint 2 replaced placeholder)', async ({
page,
context,
}) => {
const seeded = await createEngagement(redteamToken, {
name: 'AC-4.9 detail target',
start_date: '2026-11-01',
description: 'A description for detail rendering.',
});
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${seeded.id}`);
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
// Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5.
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// admin/redteam see the "New simulation" button
await expect(
page.getByRole('link', { name: /new simulation/i }),
).toBeVisible();
});
});