Files
mimic/e2e/tests/us26-templates-crud.spec.ts

373 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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 — POST technique_ids as string (not list) → 400 (isinstance guard)', async () => {
const r = await makeClient(redteamToken).post('/templates', {
name: 'US26 bad technique_ids type',
technique_ids: 'T1059',
});
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/technique_ids must be a list/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);
});
test('AC-26.7 — DELETE template does NOT cascade to instantiated simulations', async () => {
const tok = await adminToken();
// Create engagement
const engR = await makeClient(tok).post('/engagements', {
name: 'US26 cascade eng',
start_date: '2026-01-01',
});
expect(engR.status).toBe(201);
const engId = engR.data.id as number;
// Create template with distinct RT fields
const tmpl = await createTemplate(redteamToken, {
name: 'US26 cascade template',
description: 'cascade test desc',
commands: 'cascade cmd',
tactic_ids: ['TA0007'],
});
// Instantiate simulation from template
const simR = await makeClient(redteamToken).post(`/engagements/${engId}/simulations`, {
template_id: tmpl.id,
});
expect(simR.status).toBe(201);
const simId = simR.data.id as number;
// Delete the template
const del = await makeClient(redteamToken).delete(`/templates/${tmpl.id}`);
expect(del.status).toBe(204);
// Simulation must still exist with RT fields copied at instantiation time
const simCheck = await makeClient(redteamToken).get(`/simulations/${simId}`);
expect(simCheck.status).toBe(200);
expect(simCheck.data.name).toBe('US26 cascade template');
expect(simCheck.data.description).toBe('cascade test desc');
expect(simCheck.data.commands).toBe('cascade cmd');
// Cleanup
await makeClient(tok).delete(`/simulations/${simId}`);
await makeClient(tok).delete(`/engagements/${engId}`);
});
// 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]));
});
});