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>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
/**
|
|
* 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]));
|
|
});
|
|
});
|