feat: sprint 5 — simulation templates + instantiation + nav + dropdown #8

Merged
knacky merged 11 commits from sprint/5-templates into main 2026-06-07 16:08:39 +00:00
2 changed files with 107 additions and 0 deletions
Showing only changes of commit 54959c7d5b - Show all commits

View File

@@ -153,6 +153,15 @@ test.describe('US-26 — templates CRUD', () => {
expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i); 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 () => { test('AC-26.4 — SOC POST → 403', async () => {
const r = await makeClient(socToken).post('/templates', { name: 'soc template attempt' }); const r = await makeClient(socToken).post('/templates', { name: 'soc template attempt' });
expect(r.status).toBe(403); expect(r.status).toBe(403);
@@ -239,6 +248,47 @@ test.describe('US-26 — templates CRUD', () => {
await deleteTemplate(redteamToken, t.id); 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 // AC-26.8 — UI /admin/templates page
test('AC-26.8 — /admin/templates page is accessible to redteam, shows table + New button', async ({ test('AC-26.8 — /admin/templates page is accessible to redteam, shows table + New button', async ({
page, page,

View File

@@ -336,4 +336,61 @@ test.describe('US-27 — instantiate from template', () => {
await deleteSimulation(redteamToken, simId); await deleteSimulation(redteamToken, simId);
}); });
// NIT 1 — Dropdown closes on Escape key and on outside click
test('NIT-1 — dropdown closes on Escape key press', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
// Menu is open
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
// Press Escape
await page.keyboard.press('Escape');
await expect(page.getByTestId('from-template-btn')).not.toBeVisible({ timeout: 3_000 });
});
test('NIT-1 — dropdown closes when clicking outside', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
// Click somewhere outside the dropdown (page heading)
await page.getByRole('heading').first().click({ force: true });
await expect(page.getByTestId('from-template-btn')).not.toBeVisible({ timeout: 3_000 });
});
// NIT 2 — Empty-engagement SimulationList still shows dropdown
test('NIT-2 — engagement with 0 simulations still shows New simulation dropdown', async ({
page,
context,
}) => {
// Create a fresh engagement with no simulations
const eng = await createEngagement(redteamToken, {
name: 'US27 empty eng dropdown',
start_date: '2026-01-01',
});
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${eng.id}`);
// Primary button visible even in empty state
await expect(page.getByTestId('new-simulation-btn')).toBeVisible({ timeout: 5_000 });
// Chevron also visible and functional
await page.getByTestId('new-simulation-dropdown-toggle').click();
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
const tok = await adminToken();
await deleteEngagement(tok, eng.id);
});
}); });