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>
274 lines
9.6 KiB
TypeScript
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();
|
|
});
|
|
});
|