feat: sprint 5 — simulation templates + instantiation + nav + dropdown #8
322
e2e/tests/us26-templates-crud.spec.ts
Normal file
322
e2e/tests/us26-templates-crud.spec.ts
Normal 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]));
|
||||
});
|
||||
});
|
||||
339
e2e/tests/us27-instantiate-from-template.spec.ts
Normal file
339
e2e/tests/us27-instantiate-from-template.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
136
e2e/tests/us28-templates-nav.spec.ts
Normal file
136
e2e/tests/us28-templates-nav.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -265,9 +265,7 @@ test.describe('US-4 — engagement CRUD', () => {
|
||||
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();
|
||||
// Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
|
||||
await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,15 +156,13 @@ test.describe('US-7 — simulation create', () => {
|
||||
// The created simulation row is visible
|
||||
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
|
||||
|
||||
// "New simulation" button visible for redteam
|
||||
await expect(
|
||||
page.getByRole('link', { name: /new simulation/i }),
|
||||
).toBeVisible();
|
||||
// Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
|
||||
await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
|
||||
|
||||
// SOC should NOT see "New simulation" button
|
||||
// SOC should NOT see "New simulation" dropdown
|
||||
await seedTokenInStorage(context, socToken);
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user