test(e2e): sprint 5 acceptance tests — US-26 → US-28 + dropdown adaptations

Add three new spec files:
- us26-templates-crud: API CRUD (AC-26.3–26.7) + UI list/form/delete/redirect (AC-26.8)
- us27-instantiate-from-template: template_id copy + name override + 404 + decoupling
  (AC-27.1–27.3) + no auto-transition/engagement-activate (AC-27.4–27.5) + dropdown
  UI + picker modal + empty state + SOC gate (AC-27.6–27.7)
- us28-templates-nav: Templates link admin+redteam only, SOC redirect, form editable (AC-28.1–28.3)

Adapt sprint 2/3 e2e for sprint 5 dropdown:
- us4-engagements: getByRole link "New simulation" → getByTestId "new-simulation-btn"
- us7-simulation-create: same — split-button dropdown replaced the link

Suite: 201 passed (1 pre-existing flaky in us3 re DB state, unrelated to sprint 5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-28 07:15:04 +02:00
parent 55f993fa24
commit 7c011db6d9
5 changed files with 803 additions and 10 deletions

View File

@@ -0,0 +1,322 @@
/**
* US-26 — Admin/redteam creates and manages simulation templates.
* Covers AC-26.3 → AC-26.8 (API CRUD + UI).
* AC-26.1/2 (model + migration) tested implicitly via API assertions.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
deleteUserByUsername,
ensureUser,
login,
makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us26-redteam';
const SOC_USER = 'us26-soc';
const PASS = 'us26-pass-strong';
interface Template {
id: number;
name: string;
description: string | null;
commands: string | null;
prerequisites: string | null;
techniques: { id: string; name: string }[];
tactics: { id: string; name: string }[];
created_at: string;
updated_at: string | null;
created_by: { id: number; username: string };
}
async function createTemplate(
token: string,
payload: { name: string; description?: string; commands?: string; technique_ids?: string[]; tactic_ids?: string[] },
): Promise<Template> {
// Delete first if already exists (idempotent for retry safety)
const list = await makeClient(token).get('/templates');
if (list.status === 200) {
const existing = (list.data as Template[]).find((t) => t.name === payload.name);
if (existing) await makeClient(token).delete(`/templates/${existing.id}`);
}
const r = await makeClient(token).post('/templates', payload);
if (r.status !== 201) throw new Error(`create template: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Template;
}
async function deleteTemplate(token: string, id: number): Promise<void> {
await makeClient(token).delete(`/templates/${id}`);
}
test.describe('US-26 — templates 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;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u);
} catch { /* noop */ }
});
// AC-26.3 — GET /api/templates
test('AC-26.3 — GET /api/templates returns list sorted by name ASC', async () => {
const t1 = await createTemplate(redteamToken, { name: 'US26 Zebra template' });
const t2 = await createTemplate(redteamToken, { name: 'US26 Alpha template' });
const r = await makeClient(redteamToken).get('/templates');
expect(r.status).toBe(200);
expect(Array.isArray(r.data)).toBe(true);
const names = (r.data as Template[])
.filter((t) => ['US26 Zebra template', 'US26 Alpha template'].includes(t.name))
.map((t) => t.name);
expect(names).toEqual(['US26 Alpha template', 'US26 Zebra template']);
await deleteTemplate(redteamToken, t1.id);
await deleteTemplate(redteamToken, t2.id);
});
test('AC-26.3 — GET /api/templates serializes techniques + tactics', async () => {
const t = await createTemplate(redteamToken, {
name: 'US26 mitre template',
tactic_ids: ['TA0007'],
});
const r = await makeClient(redteamToken).get('/templates');
expect(r.status).toBe(200);
const found = (r.data as Template[]).find((x) => x.id === t.id);
expect(found).toBeTruthy();
expect(Array.isArray(found!.techniques)).toBe(true);
expect(Array.isArray(found!.tactics)).toBe(true);
expect(found!.tactics[0].id).toBe('TA0007');
expect(found!.tactics[0].name).toBe('Discovery');
expect(found!.created_by.username).toBe(REDTEAM_USER);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.3 — SOC GET /api/templates → 403', async () => {
const r = await makeClient(socToken).get('/templates');
expect(r.status).toBe(403);
});
// AC-26.4 — POST /api/templates
test('AC-26.4 — POST creates template with all fields', async () => {
const r = await makeClient(redteamToken).post('/templates', {
name: 'US26 full template',
description: 'test desc',
commands: 'cmd1\ncmd2',
prerequisites: 'prereq1',
tactic_ids: ['TA0001'],
});
expect(r.status).toBe(201);
expect(r.data.name).toBe('US26 full template');
expect(r.data.description).toBe('test desc');
expect(r.data.commands).toBe('cmd1\ncmd2');
expect(r.data.prerequisites).toBe('prereq1');
expect(r.data.tactics).toHaveLength(1);
expect(r.data.tactics[0].id).toBe('TA0001');
expect(r.data.updated_at).toBeNull();
await deleteTemplate(redteamToken, r.data.id as number);
});
test('AC-26.4 — POST name empty → 400', async () => {
const r = await makeClient(redteamToken).post('/templates', { name: '' });
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/name/i);
});
test('AC-26.4 — POST duplicate name → 409', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 dup name' });
const r = await makeClient(redteamToken).post('/templates', { name: 'US26 dup name' });
expect(r.status).toBe(409);
expect(r.data.error).toMatch(/template name already exists/i);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.4 — POST unknown tactic_id → 400', async () => {
const r = await makeClient(redteamToken).post('/templates', {
name: 'US26 bad tactic',
tactic_ids: ['TA9999'],
});
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i);
});
test('AC-26.4 — SOC POST → 403', async () => {
const r = await makeClient(socToken).post('/templates', { name: 'soc template attempt' });
expect(r.status).toBe(403);
});
// AC-26.5 — GET /api/templates/<tid>
test('AC-26.5 — GET /api/templates/:id returns 200 with full data', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 get single' });
const r = await makeClient(redteamToken).get(`/templates/${t.id}`);
expect(r.status).toBe(200);
expect(r.data.id).toBe(t.id);
expect(r.data.name).toBe('US26 get single');
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.5 — GET /api/templates/:id unknown → 404', async () => {
const r = await makeClient(redteamToken).get('/templates/999999');
expect(r.status).toBe(404);
});
test('AC-26.5 — SOC GET /api/templates/:id → 403', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 soc get single' });
const r = await makeClient(socToken).get(`/templates/${t.id}`);
expect(r.status).toBe(403);
await deleteTemplate(redteamToken, t.id);
});
// AC-26.6 — PATCH /api/templates/<tid>
test('AC-26.6 — PATCH updates fields and sets updated_at', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 patch me' });
expect(t.updated_at).toBeNull();
const r = await makeClient(redteamToken).patch(`/templates/${t.id}`, {
description: 'patched desc',
commands: 'new cmd',
tactic_ids: ['TA0007'],
});
expect(r.status).toBe(200);
expect(r.data.description).toBe('patched desc');
expect(r.data.commands).toBe('new cmd');
expect(r.data.tactics[0].id).toBe('TA0007');
expect(r.data.updated_at).toBeTruthy();
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.6 — PATCH name conflict → 409', async () => {
const t1 = await createTemplate(redteamToken, { name: 'US26 conflict A' });
const t2 = await createTemplate(redteamToken, { name: 'US26 conflict B' });
const r = await makeClient(redteamToken).patch(`/templates/${t2.id}`, { name: 'US26 conflict A' });
expect(r.status).toBe(409);
expect(r.data.error).toMatch(/template name already exists/i);
await deleteTemplate(redteamToken, t1.id);
await deleteTemplate(redteamToken, t2.id);
});
test('AC-26.6 — PATCH same name (no-op rename) → 200', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 same name' });
const r = await makeClient(redteamToken).patch(`/templates/${t.id}`, { name: 'US26 same name' });
expect(r.status).toBe(200);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.6 — SOC PATCH → 403', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 soc patch target' });
const r = await makeClient(socToken).patch(`/templates/${t.id}`, { description: 'hacked' });
expect(r.status).toBe(403);
await deleteTemplate(redteamToken, t.id);
});
// AC-26.7 — DELETE /api/templates/<tid>
test('AC-26.7 — DELETE returns 204 and template no longer GETable', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 delete me' });
const del = await makeClient(redteamToken).delete(`/templates/${t.id}`);
expect(del.status).toBe(204);
const r = await makeClient(redteamToken).get(`/templates/${t.id}`);
expect(r.status).toBe(404);
});
test('AC-26.7 — SOC DELETE → 403', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 soc delete target' });
const r = await makeClient(socToken).delete(`/templates/${t.id}`);
expect(r.status).toBe(403);
await deleteTemplate(redteamToken, t.id);
});
// AC-26.8 — UI /admin/templates page
test('AC-26.8 — /admin/templates page is accessible to redteam, shows table + New button', async ({
page,
context,
}) => {
const t = await createTemplate(redteamToken, { name: 'US26 UI list template' });
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates');
// Page title or heading
await expect(page.getByRole('heading', { name: /templates/i })).toBeVisible({ timeout: 5_000 });
// Table with template row
await expect(page.getByRole('row', { name: /US26 UI list template/i })).toBeVisible();
// "New" link in header (list is non-empty — empty state link says "New template")
const newLink = page.getByRole('link', { name: /^new$/i }).first();
await expect(newLink).toBeVisible();
// Edit button on the row
await expect(page.getByRole('link', { name: /edit/i }).first()).toBeVisible();
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.8 — /admin/templates shows Delete button per row', async ({
page,
context,
}) => {
const t = await createTemplate(redteamToken, { name: 'US26 UI delete btn' });
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates');
await expect(page.getByRole('row', { name: /US26 UI delete btn/i })).toBeVisible();
await expect(page.getByRole('button', { name: /delete/i }).first()).toBeVisible();
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.8 — SOC cannot access /admin/templates (redirected)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/admin/templates');
// ProtectedRoute redirects SOC to /engagements
await expect(page).toHaveURL(/\/engagements/, { timeout: 5_000 });
});
test('AC-26.8 — "New" link navigates to /admin/templates/new', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates');
// Header "New" link (when list is non-empty)
await page.getByRole('link', { name: /^new$/i }).first().click();
await expect(page).toHaveURL(/\/admin\/templates\/new/);
});
test('AC-26.8 — TemplateFormPage saves template and redirects to edit', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates/new');
await page.locator('#tpl-name').fill('US26 form create');
await page.getByRole('button', { name: /save/i }).click();
// Redirects to /admin/templates/:id/edit after creation
await expect(page).toHaveURL(/\/admin\/templates\/\d+\/edit/, { timeout: 5_000 });
// Clean up — get id from URL
const url = page.url();
const match = url.match(/\/admin\/templates\/(\d+)\/edit/);
if (match) await deleteTemplate(redteamToken, parseInt(match[1]));
});
});

View File

@@ -0,0 +1,339 @@
/**
* US-27 — Redteam instantiates a template into an engagement.
* Covers AC-27.1 → AC-27.7.
* AC-27.3 (decoupling) tested via API: modifying instance ≠ modifying template and vice-versa.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us27-redteam';
const SOC_USER = 'us27-soc';
const PASS = 'us27-pass-strong';
interface Template { id: number; name: string; techniques: unknown[]; tactics: unknown[]; [k: string]: unknown; }
interface Simulation { id: number; name: string; status: string; techniques: unknown[]; tactic_ids?: unknown[]; [k: string]: unknown; }
async function createTemplate(token: string, payload: Record<string, unknown>): Promise<Template> {
const r = await makeClient(token).post('/templates', payload);
if (r.status !== 201) throw new Error(`create template: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Template;
}
async function deleteTemplate(token: string, id: number): Promise<void> {
await makeClient(token).delete(`/templates/${id}`);
}
async function deleteSimulation(token: string, id: number): Promise<void> {
await makeClient(token).delete(`/simulations/${id}`);
}
test.describe('US-27 — instantiate from template', () => {
let redteamToken: string;
let socToken: string;
let engagementId: number;
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;
const eng = await createEngagement(redteamToken, {
name: 'US-27 Engagement',
start_date: '2026-01-01',
});
engagementId = eng.id;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteEngagement(tok, engagementId);
for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u);
} catch { /* noop */ }
});
// AC-27.1 — POST with template_id copies all RT fields
test('AC-27.1 — POST with template_id copies name, description, commands, prerequisites, techniques, tactic_ids', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 full template',
description: 'template desc',
commands: 'cmd1\ncmd2',
prerequisites: 'prereq',
tactic_ids: ['TA0007'],
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
expect(r.status).toBe(201);
const sim = r.data as Simulation;
expect(sim.name).toBe('US27 full template');
expect(sim.description).toBe('template desc');
expect(sim.commands).toBe('cmd1\ncmd2');
expect(sim.prerequisites).toBe('prereq');
expect(sim.status).toBe('pending');
expect(sim.executed_at).toBeNull();
expect(Array.isArray(sim.tactics)).toBe(true);
expect((sim.tactics as { id: string }[])[0]?.id).toBe('TA0007');
// SOC fields null
expect(sim.soc_comment).toBeNull();
await deleteSimulation(redteamToken, sim.id);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.2 — name override in body wins over template name
test('AC-27.2 — POST with template_id + name override: body name wins', async () => {
const tmpl = await createTemplate(redteamToken, { name: 'US27 override base' });
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
name: 'Custom override name',
});
expect(r.status).toBe(201);
expect(r.data.name).toBe('Custom override name');
await deleteSimulation(redteamToken, r.data.id as number);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.2 — POST without template_id keeps original blank-create behavior
test('AC-27.2 — POST without template_id creates blank simulation (name required)', async () => {
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
name: 'Blank sim no template',
});
expect(r.status).toBe(201);
expect(r.data.name).toBe('Blank sim no template');
expect(r.data.techniques).toHaveLength(0);
await deleteSimulation(redteamToken, r.data.id as number);
});
// AC-27.1 — template_id inexistant → 404
test('AC-27.1 — POST with unknown template_id → 404', async () => {
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
name: 'orphan',
template_id: 999999,
});
expect(r.status).toBe(404);
expect(r.data.error).toMatch(/template not found/i);
});
// AC-27.3 — decoupling: modifying instance does not touch template
test('AC-27.3 — modifying instance does NOT affect template', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 decouple template',
description: 'original desc',
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
const sim = r.data as Simulation;
// Modify the instance
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { description: 'changed on instance' });
// Template unchanged
const tCheck = await makeClient(redteamToken).get(`/templates/${tmpl.id}`);
expect(tCheck.data.description).toBe('original desc');
await deleteSimulation(redteamToken, sim.id);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.3 — decoupling: modifying template does not touch existing instance
test('AC-27.3 — modifying template does NOT affect already-created instance', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 decouple template 2',
commands: 'original cmd',
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
const sim = r.data as Simulation;
// Modify the template
await makeClient(redteamToken).patch(`/templates/${tmpl.id}`, { commands: 'modified cmd' });
// Instance unchanged
const sCheck = await makeClient(redteamToken).get(`/simulations/${sim.id}`);
expect(sCheck.data.commands).toBe('original cmd');
await deleteSimulation(redteamToken, sim.id);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.4 — auto-transition NOT triggered on creation from template
test('AC-27.4 — creation from template with techniques does NOT auto-transition (stays pending)', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 no auto-transition',
tactic_ids: ['TA0007'],
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
expect(r.status).toBe(201);
expect(r.data.status).toBe('pending');
await deleteSimulation(redteamToken, r.data.id as number);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.5 — engagement auto-status NOT triggered by instantiation
test('AC-27.5 — engagement stays planned after instantiation from template', async () => {
const eng = await createEngagement(redteamToken, {
name: 'US27 auto-status check',
start_date: '2026-01-01',
});
const tmpl = await createTemplate(redteamToken, {
name: 'US27 auto-status template',
tactic_ids: ['TA0001'],
});
const r = await makeClient(redteamToken).post(`/engagements/${eng.id}/simulations`, {
template_id: tmpl.id,
});
expect(r.status).toBe(201);
// Engagement status must still be planned
const engCheck = await makeClient(redteamToken).get(`/engagements/${eng.id}`);
expect(engCheck.data.status).toBe('planned');
await deleteSimulation(redteamToken, r.data.id as number);
await deleteTemplate(redteamToken, tmpl.id);
const tok = await adminToken();
await deleteEngagement(tok, eng.id);
});
// AC-27.6 — UI: dropdown + TemplatePickerModal
test('AC-27.6 — EngagementDetailPage: dropdown toggle shows Blank + From template… options', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Main "New" button visible (direct Blank action)
const newBtn = page.getByTestId('new-simulation-btn');
await expect(newBtn).toBeVisible({ timeout: 5_000 });
// Chevron toggle opens dropdown
await page.getByTestId('new-simulation-dropdown-toggle').click();
await expect(page.getByRole('menuitem', { name: /blank/i })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /from template/i })).toBeVisible();
});
test('AC-27.6 — clicking Blank navigates to /engagements/:eid/simulations/new', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-btn').click();
await expect(page).toHaveURL(/\/engagements\/\d+\/simulations\/new/);
});
test('AC-27.6 — "From template…" opens TemplatePickerModal', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await page.getByTestId('from-template-btn').click();
// Modal appears
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText(/from template/i);
});
test('AC-27.6 — TemplatePickerModal empty state when no templates exist', async ({
page,
context,
}) => {
// Delete ALL templates (cross-suite leftovers included) to get a clean empty state
const tok = await adminToken();
const allTmpl = await makeClient(tok).get('/templates');
for (const t of allTmpl.data as Template[]) {
await deleteTemplate(tok, t.id);
}
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await page.getByTestId('from-template-btn').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText(/no templates available/i);
});
test('AC-27.6 — selecting template from picker creates simulation and navigates to edit page', async ({
page,
context,
}) => {
const tmpl = await createTemplate(redteamToken, { name: 'US27 picker select template' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await page.getByTestId('from-template-btn').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Click the template row
await dialog.getByTestId(`template-row-${tmpl.id}`).click();
// Modal closes, redirect to /engagements/:eid/simulations/:sid/edit
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
await expect(page).toHaveURL(
new RegExp(`/engagements/${engagementId}/simulations/\\d+/edit`),
{ timeout: 8_000 },
);
// Simulation name matches template name (use heading to avoid strict mode with toast)
await expect(page.getByRole('heading', { name: 'US27 picker select template' })).toBeVisible();
// Clean up: extract sim id from URL
const url = page.url();
const m = url.match(/\/simulations\/(\d+)\/edit/);
if (m) await deleteSimulation(redteamToken, parseInt(m[1]));
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.7 — SOC has no access to the new simulation button
test('AC-27.7 — SOC does not see the New simulation dropdown on EngagementDetailPage', async ({
page,
context,
}) => {
// Advance a sim to review_required so SOC can access the engagement page
const sim = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
name: 'US27 soc visibility sim',
});
const simId = sim.data.id as number;
await makeClient(redteamToken).patch(`/simulations/${simId}`, { name: 'US27 soc vis' });
await makeClient(redteamToken).post(`/simulations/${simId}/transition`, { to: 'review_required' });
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}`);
// No new-simulation button for SOC
await expect(page.getByTestId('new-simulation-btn')).not.toBeVisible();
await expect(page.getByTestId('new-simulation-dropdown-toggle')).not.toBeVisible();
await deleteSimulation(redteamToken, simId);
});
});

View File

@@ -0,0 +1,136 @@
/**
* US-28 — Admin/redteam access templates from the nav.
* Covers AC-28.1 (Templates link in topbar), AC-28.2 (ProtectedRoute SOC redirect),
* AC-28.3 (page is always edit-capable, no read-only mode).
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
deleteUserByUsername,
ensureUser,
login,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us28-redteam';
const SOC_USER = 'us28-soc';
const PASS = 'us28-pass-strong';
test.describe('US-28 — templates nav', () => {
let redteamToken: string;
let socToken: string;
let adminTok: string;
test.beforeAll(async () => {
adminTok = await adminToken();
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;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u);
} catch { /* noop */ }
});
// AC-28.1 — Templates nav link visible to admin + redteam
test('AC-28.1 — redteam sees "Templates" link in topbar nav', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
const link = page.getByRole('link', { name: /^templates$/i });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', '/admin/templates');
});
test('AC-28.1 — admin sees "Templates" link in topbar nav', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto('/engagements');
await expect(page.getByRole('link', { name: /^templates$/i })).toBeVisible();
});
test('AC-28.1 — SOC does NOT see "Templates" link in topbar nav', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/engagements');
// Wait for page to fully load before asserting absence
await page.waitForLoadState('networkidle');
await expect(page.getByRole('link', { name: /^templates$/i })).not.toBeVisible();
});
test('AC-28.1 — clicking Templates link navigates to /admin/templates', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
await page.getByRole('link', { name: /^templates$/i }).click();
await expect(page).toHaveURL(/\/admin\/templates/);
});
// AC-28.2 — SOC typing /admin/templates URL directly → redirected
test('AC-28.2 — SOC direct URL /admin/templates → redirected to /engagements', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/admin/templates');
await expect(page).toHaveURL(/\/engagements/, { timeout: 5_000 });
});
test('AC-28.2 — SOC direct URL /admin/templates/new → redirected to /engagements', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/admin/templates/new');
await expect(page).toHaveURL(/\/engagements/, { timeout: 5_000 });
});
// AC-28.3 — Page assumes canEdit=true (form inputs are never disabled for admin/redteam)
test('AC-28.3 — TemplateFormPage for redteam: name input is editable (no read-only mode)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates/new');
const nameInput = page.getByLabel(/name/i).first();
await expect(nameInput).toBeVisible();
await expect(nameInput).toBeEnabled();
// Should be able to type
await nameInput.fill('AC-28.3 editability test');
await expect(nameInput).toHaveValue('AC-28.3 editability test');
});
test('AC-28.3 — admin has same edit access on /admin/templates', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto('/admin/templates');
// Page loads (not redirected)
await expect(page).toHaveURL(/\/admin\/templates/);
await expect(page.getByRole('heading', { name: 'Templates', exact: true })).toBeVisible({ timeout: 5_000 });
// "New" link in header when templates exist, "New template" in empty state — either is fine
await expect(page.getByRole('link', { name: /new( template)?/i }).first()).toBeVisible();
});
});

View File

@@ -265,9 +265,7 @@ test.describe('US-4 — engagement CRUD', () => {
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible(); 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. // Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5.
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// admin/redteam see the "New simulation" button // Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
await expect( await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
page.getByRole('link', { name: /new simulation/i }),
).toBeVisible();
}); });
}); });

View File

@@ -156,15 +156,13 @@ test.describe('US-7 — simulation create', () => {
// The created simulation row is visible // The created simulation row is visible
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible(); await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
// "New simulation" button visible for redteam // Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
await expect( await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
page.getByRole('link', { name: /new simulation/i }),
).toBeVisible();
// SOC should NOT see "New simulation" button // SOC should NOT see "New simulation" dropdown
await seedTokenInStorage(context, socToken); await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}`); await page.goto(`/engagements/${engagementId}`);
await expect(page.getByRole('link', { name: /new simulation/i })).toHaveCount(0); await expect(page.getByTestId('new-simulation-btn')).not.toBeVisible();
await deleteSimulation(redteamToken, sim.id); await deleteSimulation(redteamToken, sim.id);
}); });