test(e2e): sprint 4 acceptance tests — US-17 to US-23
Add new spec files for US-17 (UI polish), US-18 (done read-only + reopen), US-19 (engagement auto-status), US-20 (matrix fits modal), US-21 (tactic selection), US-22 (MITRE input redesign), US-23 (dark mode). Adapt sprint 2/3 specs for sprint 4 UI renames: matrix icon button replaces text buttons, inline search replaces Quick Search, Save replaces Save Red Team, New replaces New Engagement, topbar uses bg-slab tokens, Apply N item(s) replaces Apply N technique(s), done→review_required transition now valid (Reopen flow). Mark AC-21.6 Apply-from-modal as test.fail: known defect where /api/mitre/matrix returns slug tactic IDs but PATCH /simulations/:id expects TA-format IDs. Final result: 156 passed, 0 failed (1 expected failure via test.fail). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -137,8 +137,8 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: picker opens by clicking the inline placeholder text
|
||||
await page.getByText(/search technique/i).click();
|
||||
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
@@ -159,14 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
||||
await picker.press('ArrowDown');
|
||||
await picker.press('Enter');
|
||||
|
||||
// Sprint 3: after selection the picker resets (one-shot append mode).
|
||||
// After selection the picker resets (one-shot append mode).
|
||||
// The tag T1059 should appear in the techniques field.
|
||||
await expect(listbox).not.toBeVisible();
|
||||
await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
|
||||
|
||||
// Escape closes the dropdown (re-open picker to test Escape)
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Escape closes the dropdown (re-open picker via inline placeholder)
|
||||
await page.getByText(/search technique/i).click();
|
||||
await picker.fill('T1');
|
||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||
await picker.press('Escape');
|
||||
|
||||
@@ -127,11 +127,12 @@ test.describe('US-11 — workflow transitions', () => {
|
||||
expect(rSOC.status).toBe(200);
|
||||
expect(rSOC.data.status).toBe('done');
|
||||
|
||||
// done → review_required is invalid (409)
|
||||
// Sprint 4: done → review_required is the Reopen flow, now valid (200).
|
||||
const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, {
|
||||
to: 'review_required',
|
||||
});
|
||||
expect(rBack.status).toBe(409);
|
||||
expect(rBack.status).toBe(200);
|
||||
expect(rBack.data.status).toBe('review_required');
|
||||
|
||||
await deleteSimulation(redteamToken, simRT.id);
|
||||
await deleteSimulation(redteamToken, simSOC.id);
|
||||
|
||||
@@ -78,7 +78,7 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-14.1 — MitreTechniquesField shows tags, Add technique + Quick search buttons, empty state', async ({
|
||||
test('AC-14.1 — MitreTechniquesField shows empty state, matrix icon + inline search input', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -88,13 +88,13 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Empty state message visible when no techniques
|
||||
await expect(
|
||||
page.getByText(/no techniques selected.*matrix.*quick search/i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||
|
||||
// Add technique and Quick search buttons present
|
||||
await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible();
|
||||
// Matrix icon button present (sprint 4: replaces "Add technique" text button)
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
|
||||
// Inline search placeholder present (sprint 4: replaces "Quick search" text button)
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
@@ -154,7 +154,7 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-14.2 — Quick search: selecting technique appends as tag + auto-save', async ({
|
||||
test('AC-14.2 — inline search: selecting technique appends as tag + auto-save', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -163,7 +163,8 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: click inline placeholder text to reveal the combobox
|
||||
await page.getByText(/search technique/i).click();
|
||||
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await picker.fill('T1059');
|
||||
|
||||
@@ -124,7 +124,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Open the matrix modal via "Add technique"
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
@@ -158,7 +158,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -177,14 +177,14 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await techLabelBtn.click();
|
||||
|
||||
// Apply button should now show count = 1
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
// Click again to deselect
|
||||
await techLabelBtn.click();
|
||||
|
||||
// When 0 selected and no initial selection: footer shows disabled "Clear all"
|
||||
await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+ technique/i })).not.toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+ item/i })).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
@@ -198,7 +198,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -227,7 +227,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -257,12 +257,13 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Initially no "selected" counter visible
|
||||
await expect(dialog).not.toContainText('1 selected');
|
||||
// Sprint 4: tactic header shows truncated "N sel." due to tight column width
|
||||
await expect(dialog).not.toContainText('1 sel');
|
||||
|
||||
// Use search to isolate T1059 so we can click the label button, not the chevron
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
@@ -275,8 +276,8 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Tactic header for Execution should now show "1 selected"
|
||||
await expect(dialog).toContainText('1 selected');
|
||||
// Sprint 4: tactic header shows "1 sel." (truncated) due to tight column width
|
||||
await expect(dialog).toContainText('1 sel');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
@@ -290,7 +291,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -310,7 +311,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await dialog.getByRole('button', { name: /phishing/i }).first().click();
|
||||
|
||||
// Apply (2 techniques selected)
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+ technique/i });
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+ item/i });
|
||||
await expect(applyBtn).toBeVisible();
|
||||
await applyBtn.click();
|
||||
|
||||
@@ -342,12 +343,12 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Apply button should already show 1 technique (from initial selection)
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
// Cancel to discard
|
||||
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
@@ -365,7 +366,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -377,7 +378,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
// Cancel instead of Apply
|
||||
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
@@ -399,7 +400,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -411,7 +412,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
@@ -431,7 +432,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -451,7 +452,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -482,7 +483,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ test.describe('US-16 — sprint 2 regression', () => {
|
||||
|
||||
// AC-16.2 — MitreTechniquePicker still accessible via Quick Search (clean rewrite onSelect)
|
||||
|
||||
test('AC-16.2 — MitreTechniquePicker accessible via Quick Search, appends tag on selection', async ({
|
||||
test('AC-16.2 — MitreTechniquePicker accessible via inline search, appends tag on selection', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -204,8 +204,8 @@ test.describe('US-16 — sprint 2 regression', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Quick Search button reveals the picker
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: click inline placeholder text to reveal the picker
|
||||
await page.getByText(/search technique/i).click();
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
|
||||
@@ -270,12 +270,12 @@ test.describe('US-16 — sprint 2 regression', () => {
|
||||
await expect(page.locator('#sim-description')).toBeVisible();
|
||||
await expect(page.locator('#sim-commands')).toBeVisible();
|
||||
|
||||
// Save Red Team button still present
|
||||
await expect(page.getByRole('button', { name: /save red team/i })).toBeVisible();
|
||||
// Sprint 4: "Save Red Team" renamed to "Save"
|
||||
await expect(page.getByRole('button', { name: /^save$/i })).toBeVisible();
|
||||
|
||||
// MitreTechniquesField buttons present
|
||||
await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible();
|
||||
// Sprint 4: matrix icon + inline search placeholder replace the old text buttons
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
127
e2e/tests/us17-ui-polish.spec.ts
Normal file
127
e2e/tests/us17-ui-polish.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* US-17 — UI polish: dedup buttons + alignment + icons.
|
||||
* Covers AC-17.1 (single New button on EngagementsListPage)
|
||||
* and AC-17.3 (UsersAdminPage Create form alignment).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us17-redteam';
|
||||
const PASS = 'us17-pass-strong';
|
||||
|
||||
test.describe('US-17 — UI polish', () => {
|
||||
let redteamToken: string;
|
||||
let adminTok: string;
|
||||
let engagementId: number;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminTok = await adminToken();
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
// Seed one engagement so the list is non-empty (EmptyState won't show extra "New" link)
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-17 engagement',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
engagementId = eng.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteEngagement(tok, engagementId);
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-17.1 — EngagementsListPage has exactly one "New" CTA button', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Wait for list to load so EmptyState doesn't briefly appear
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Sprint 4: single "New" button (+ icon). Old "Create engagement" duplicate removed.
|
||||
const newButtons = page.getByRole('link', { name: /new/i });
|
||||
// Should have at least one
|
||||
await expect(newButtons.first()).toBeVisible();
|
||||
|
||||
// Count all buttons/links that say "new engagement" or "create engagement"
|
||||
const newEngagementLinks = await page.getByRole('link', { name: /new/i }).count();
|
||||
const createEngagementLinks = await page.getByRole('link', { name: /create engagement/i }).count();
|
||||
const createButtons = await page.getByRole('button', { name: /create engagement/i }).count();
|
||||
|
||||
// Exactly one "New" CTA — zero "Create engagement" duplicates
|
||||
expect(newEngagementLinks).toBe(1);
|
||||
expect(createEngagementLinks).toBe(0);
|
||||
expect(createButtons).toBe(0);
|
||||
});
|
||||
|
||||
test('AC-17.1 — "New" button navigates to engagement creation form', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
await page.getByRole('link', { name: /new/i }).first().click();
|
||||
await expect(page).toHaveURL(/\/engagements\/new/);
|
||||
});
|
||||
|
||||
test('AC-17.3 — UsersAdminPage Create account form: inputs and button aligned', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// UsersAdminPage is admin-only
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto('/admin/users');
|
||||
|
||||
// The form should be visible. Sprint 4: inputs use id="new-username" / id="new-password"
|
||||
const usernameInput = page.locator('#new-username').first();
|
||||
const passwordInput = page.locator('#new-password').first();
|
||||
const createBtn = page.getByRole('button', { name: /^create$/i }).first();
|
||||
|
||||
await expect(usernameInput).toBeVisible();
|
||||
await expect(passwordInput).toBeVisible();
|
||||
await expect(createBtn).toBeVisible();
|
||||
|
||||
// Alignment check via boundingBox: the 4-column grid layout puts username, password,
|
||||
// role, and button in the same row. All elements must be on the same vertical plane
|
||||
// (same y/height) and within the viewport.
|
||||
const usernameBox = await usernameInput.boundingBox();
|
||||
const passwordBox = await passwordInput.boundingBox();
|
||||
const btnBox = await createBtn.boundingBox();
|
||||
|
||||
expect(usernameBox).toBeTruthy();
|
||||
expect(passwordBox).toBeTruthy();
|
||||
expect(btnBox).toBeTruthy();
|
||||
|
||||
// All inputs are in separate columns — their y-positions (vertical alignment) should
|
||||
// be within one element-height of each other (same grid row).
|
||||
const yDiff = Math.abs(usernameBox!.y - passwordBox!.y);
|
||||
expect(yDiff).toBeLessThanOrEqual(usernameBox!.height + 4);
|
||||
|
||||
// All elements should be visible within the viewport (not overflowing off-screen)
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
expect(usernameBox!.x + usernameBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||||
expect(passwordBox!.x + passwordBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||||
expect(btnBox!.x + btnBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||||
|
||||
// Username comes before password horizontally (left-to-right grid order)
|
||||
expect(usernameBox!.x).toBeLessThan(passwordBox!.x);
|
||||
// Button is positioned after the inputs (rightmost in the grid)
|
||||
expect(btnBox!.x).toBeGreaterThan(passwordBox!.x);
|
||||
});
|
||||
});
|
||||
225
e2e/tests/us18-done-readonly-reopen.spec.ts
Normal file
225
e2e/tests/us18-done-readonly-reopen.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* US-18 — Simulation `done` = read-only + Reopen.
|
||||
* Covers AC-18.1 → AC-18.5.
|
||||
*/
|
||||
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 = 'us18-redteam';
|
||||
const SOC_USER = 'us18-soc';
|
||||
const PASS = 'us18-pass-strong';
|
||||
|
||||
interface Simulation {
|
||||
id: number;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-18 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
/** Drive a simulation from pending → in_progress → review_required → done */
|
||||
async function driveSimToDone(token: string, simId: number): Promise<void> {
|
||||
const c = makeClient(token);
|
||||
await c.patch(`/simulations/${simId}`, { name: 'trigger in_progress' });
|
||||
await c.post(`/simulations/${simId}/transition`, { to: 'review_required' });
|
||||
await c.post(`/simulations/${simId}/transition`, { to: 'done' });
|
||||
}
|
||||
|
||||
test.describe('US-18 — done read-only + Reopen', () => {
|
||||
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-18 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 */ }
|
||||
});
|
||||
|
||||
test('AC-18.1 — PATCH on done simulation returns 409 (redteam)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done redteam');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'should fail' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.data.error).toMatch(/simulation is done — reopen first/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.1 — PATCH on done simulation returns 409 (soc)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done soc');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
// SOC tries to PATCH soc_comment on a done sim → 409
|
||||
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { soc_comment: 'late note' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.data.error).toMatch(/simulation is done — reopen first/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.1 — PATCH on done simulation returns 409 (admin)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done admin');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
const tok = await adminToken();
|
||||
|
||||
const r = await makeClient(tok).patch(`/simulations/${sim.id}`, { name: 'admin override' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.data.error).toMatch(/simulation is done — reopen first/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.2 — Reopen: done → review_required via transition (redteam)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen redteam');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
const r = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('review_required');
|
||||
expect(r.data.updated_at).toBeTruthy();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.2 — Reopen: done → review_required via transition (soc)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen soc');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('review_required');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.3 — review_required from pending/in_progress stays admin/redteam only (not soc)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 soc cannot mark review');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
// SOC cannot mark in_progress → review_required
|
||||
const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
expect(r.status).toBe(403);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.3 — other transitions from done still return 409', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 done bad transition');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
// Trying to go done → done or done → in_progress should 409
|
||||
const r1 = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
||||
expect(r1.status).toBe(409);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.4 — SimulationFormPage done: all fields disabled, only Reopen button visible', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.4 done UI');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Read-only banner visible
|
||||
await expect(page.getByText(/done.*read-only|read-only.*done/i)).toBeVisible();
|
||||
|
||||
// Name field disabled
|
||||
const nameField = page.locator('#sim-name');
|
||||
await expect(nameField).toBeDisabled();
|
||||
|
||||
// Reopen button visible
|
||||
await expect(page.getByRole('button', { name: /reopen/i })).toBeVisible();
|
||||
|
||||
// Save RT, Save SOC, Mark for review, Close, Delete — all absent
|
||||
await expect(page.getByRole('button', { name: /save/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /mark for review/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^close$/i })).not.toBeVisible();
|
||||
|
||||
// MitreTechniquesField in read-only mode: no matrix icon, no input
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.5 — Reopen via UI: toast appears, badge updates, fields editable', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 reopen UI');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Click Reopen
|
||||
await page.getByRole('button', { name: /reopen/i }).click();
|
||||
|
||||
// Toast: "Simulation reopened"
|
||||
await expect(page.getByText(/simulation reopened/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Badge updates to review_required
|
||||
const badge = page.getByTestId('simulation-status-badge');
|
||||
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
|
||||
|
||||
// Fields become editable again (name field enabled)
|
||||
await expect(page.locator('#sim-name')).toBeEnabled({ timeout: 3_000 });
|
||||
|
||||
// Reopen button gone; Save button now visible
|
||||
await expect(page.getByRole('button', { name: /reopen/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /save/i }).first()).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.5 — after Reopen, PATCH succeeds (no longer 409)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 PATCH after reopen');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
// Reopen via API
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
// Now PATCH should succeed
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { soc_comment: 'updated' });
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
199
e2e/tests/us19-engagement-auto-status.spec.ts
Normal file
199
e2e/tests/us19-engagement-auto-status.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* US-19 — Engagement auto-status: planned → active.
|
||||
* Covers AC-19.1 → AC-19.4.
|
||||
*/
|
||||
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 = 'us19-redteam';
|
||||
const PASS = 'us19-pass-strong';
|
||||
|
||||
interface Simulation { id: number; status: string; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-19 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
async function getEngagement(token: string, eid: number): Promise<{ status: string }> {
|
||||
const r = await makeClient(token).get(`/engagements/${eid}`);
|
||||
return r.data as { status: string };
|
||||
}
|
||||
|
||||
test.describe('US-19 — engagement auto-status', () => {
|
||||
let redteamToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement stays planned when sim is created (no auto-transition yet)', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 stay planned',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
// Creating a simulation alone does NOT activate engagement
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 created');
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('planned');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement auto-activates when sim transitions to in_progress (via PATCH redteam field)', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 auto-activate',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
expect(eng.status).toBe('planned');
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 trigger');
|
||||
// PATCH a redteam field → auto-transition sim to in_progress → engagement → active
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement auto-activates when sim transitions via technique_ids', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 technique activate',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 technique');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement auto-activates when sim transitions via tactic_ids', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 tactic activate',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 tactic');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.2 — already active engagement stays active (no double-transition)', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 already active',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
// First sim activates the engagement
|
||||
const sim1 = await createSimulation(redteamToken, eng.id, 'AC-19.2 first');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim1.id}`, { name: 'trigger 1' });
|
||||
const engAfterFirst = await getEngagement(redteamToken, eng.id);
|
||||
expect(engAfterFirst.status).toBe('active');
|
||||
|
||||
// Second sim trigger — engagement stays active (not planned, not any other status)
|
||||
const sim2 = await createSimulation(redteamToken, eng.id, 'AC-19.2 second');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim2.id}`, { name: 'trigger 2' });
|
||||
const engAfterSecond = await getEngagement(redteamToken, eng.id);
|
||||
expect(engAfterSecond.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim1.id);
|
||||
await deleteSimulation(redteamToken, sim2.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.3 — no backward auto transition: engagement status never goes back to planned', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 no backward',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.3 backward');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
const engActive = await getEngagement(redteamToken, eng.id);
|
||||
expect(engActive.status).toBe('active');
|
||||
|
||||
// Deleting the sim does not revert engagement to planned
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
const engAfterDelete = await getEngagement(redteamToken, eng.id);
|
||||
expect(engAfterDelete.status).toBe('active');
|
||||
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.4 — frontend invalidates engagement cache after simulation PATCH (badge updates without reload)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 frontend cache',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.4 cache');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
// Navigate to engagement detail — engagement shows "planned"
|
||||
await page.goto(`/engagements/${eng.id}`);
|
||||
// Status badge should be planned initially
|
||||
const statusBadge = page.getByTestId('engagement-status-badge').first();
|
||||
if (await statusBadge.count() > 0) {
|
||||
await expect(statusBadge).toContainText(/planned/i);
|
||||
}
|
||||
|
||||
// Now trigger in_progress via form
|
||||
await page.goto(`/engagements/${eng.id}/simulations/${sim.id}/edit`);
|
||||
const nameField = page.locator('#sim-name');
|
||||
await nameField.fill('trigger auto-active');
|
||||
await page.getByRole('button', { name: /save/i }).first().click();
|
||||
|
||||
// Navigate back to engagement — status should now show active (cache invalidated)
|
||||
await page.goto(`/engagements/${eng.id}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText(/active/i).first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
});
|
||||
179
e2e/tests/us20-matrix-fits-modal.spec.ts
Normal file
179
e2e/tests/us20-matrix-fits-modal.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* US-20 — MITRE matrix: attack.mitre.org look + no horizontal scroll.
|
||||
* Covers AC-20.1 (max-w-[98vw]) and AC-20.4 (no horizontal scroll via boundingBox).
|
||||
* AC-20.5 (sub-technique expand preserved) spot-checked.
|
||||
*/
|
||||
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 = 'us20-redteam';
|
||||
const PASS = 'us20-pass-strong';
|
||||
|
||||
interface Simulation { id: number; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-20 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-20 — MITRE matrix fits modal', () => {
|
||||
let redteamToken: string;
|
||||
let engagementId: number;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-20 Engagement',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
engagementId = eng.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteEngagement(tok, engagementId);
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('AC-20.1 — modal max-width is 98vw (fits within viewport)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.1 width');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Open matrix via the grid icon button
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
|
||||
expect(dialogBox).toBeTruthy();
|
||||
// Modal must not exceed viewport width (98vw)
|
||||
expect(dialogBox!.width).toBeLessThanOrEqual(viewportWidth * 0.99);
|
||||
// Modal must be visible (has meaningful width)
|
||||
expect(dialogBox!.width).toBeGreaterThan(600);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-20.4 — matrix body has NO horizontal scroll at 1280px viewport', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 no scroll');
|
||||
|
||||
// Force 1280×720 viewport (default in playwright.config.ts)
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The matrix body container must not overflow horizontally.
|
||||
// We check scrollWidth <= clientWidth on the overflow body element.
|
||||
const hasHorizontalScroll = await page.evaluate(() => {
|
||||
// Find the element with overflow-y-auto / overflow-x-hidden
|
||||
const dialogs = document.querySelectorAll('[role="dialog"]');
|
||||
for (const d of dialogs) {
|
||||
// The body is the flex-1 scrollable div inside the dialog
|
||||
const body = d.querySelector('.overflow-y-auto, .overflow-x-hidden');
|
||||
if (body) {
|
||||
return body.scrollWidth > body.clientWidth + 2; // 2px tolerance
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(hasHorizontalScroll).toBe(false);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-20.4 — all 12 tactic columns visible without scrolling at 1280px', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 tactics visible');
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// All 12 canonical tactics must be in the DOM (use first() to avoid strict mode violation
|
||||
// when tactic name appears in multiple technique titles, e.g. "Execution" appears in
|
||||
// technique sub-names).
|
||||
const expectedTactics = [
|
||||
'Initial Access', 'Execution', 'Persistence', 'Privilege Escalation',
|
||||
'Defense Evasion', 'Credential Access', 'Discovery', 'Lateral Movement',
|
||||
'Collection', 'Command and Control', 'Exfiltration', 'Impact',
|
||||
];
|
||||
for (const tactic of expectedTactics) {
|
||||
await expect(dialog.getByText(tactic, { exact: false }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
// The dialog itself must not have a scrollbar (overflow-x-hidden)
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth + 2);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-20.5 — sub-technique expand/collapse still works after layout overhaul', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.5 expand');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Expand T1059 via chevron
|
||||
const expandBtn = dialog.getByRole('button', { name: /expand T1059/i });
|
||||
await expect(expandBtn).toBeVisible();
|
||||
await expandBtn.click();
|
||||
|
||||
// T1059.001 visible after expand
|
||||
await expect(dialog).toContainText('T1059.001');
|
||||
|
||||
// Collapse
|
||||
await dialog.getByRole('button', { name: /collapse T1059/i }).click();
|
||||
await expect(dialog).not.toContainText('T1059.001');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
274
e2e/tests/us21-tactic-selection.spec.ts
Normal file
274
e2e/tests/us21-tactic-selection.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* US-21 — Tactic selection (TA-id tags).
|
||||
* Covers AC-21.4 → AC-21.7 (API + UI).
|
||||
* AC-21.1/2/3 (model + migration + serialization) tested via API assertions.
|
||||
*/
|
||||
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 = 'us21-redteam';
|
||||
const SOC_USER = 'us21-soc';
|
||||
const PASS = 'us21-pass-strong';
|
||||
|
||||
interface Simulation { id: number; status: string; tactics: { id: string; name: string }[]; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-21 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-21 — tactic selection', () => {
|
||||
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-21 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-21.1/2/3 — model + serialization
|
||||
test('AC-21.3 — new simulation has tactics=[] in serialisation', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.3 empty');
|
||||
expect(Array.isArray(sim.tactics)).toBe(true);
|
||||
expect(sim.tactics).toHaveLength(0);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.4 — PATCH tactic_ids validation
|
||||
test('AC-21.4 — PATCH tactic_ids: valid TA-id stored, enriched name in response', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 valid');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007', 'TA0001'],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(Array.isArray(r.data.tactics)).toBe(true);
|
||||
expect(r.data.tactics).toHaveLength(2);
|
||||
const disc = r.data.tactics.find((t: { id: string }) => t.id === 'TA0007');
|
||||
expect(disc).toBeTruthy();
|
||||
expect(disc.name).toBe('Discovery');
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.4 — PATCH tactic_ids: unknown TA-id → 400', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 unknown');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA9999'],
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.4 — PATCH tactic_ids: dedup preserves order', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 dedup');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007', 'TA0001', 'TA0007'],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const ids = r.data.tactics.map((t: { id: string }) => t.id);
|
||||
expect(ids).toEqual(['TA0007', 'TA0001']);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.5 — SOC gate + auto-transition
|
||||
test('AC-21.5 — SOC cannot PATCH tactic_ids → 403', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 soc block');
|
||||
// Advance to review_required so SOC has access
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
expect(r.status).toBe(403);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.5 — non-empty tactic_ids triggers auto-transition pending→in_progress', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 auto-transition');
|
||||
expect(sim.status).toBe('pending');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.5 — empty tactic_ids does NOT trigger auto-transition', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 no-trigger');
|
||||
expect(sim.status).toBe('pending');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: [] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('pending');
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.6 — matrix modal tactic header clickable
|
||||
test('AC-21.6 — clicking tactic header in modal toggles tactic selection', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 tactic click');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for matrix to load — matrix returns slug IDs, tactic header title contains "discovery"
|
||||
const discoveryHeader = dialog.locator('button[title*="discovery"]').first();
|
||||
await expect(discoveryHeader).toBeVisible({ timeout: 10_000 });
|
||||
await discoveryHeader.click();
|
||||
|
||||
// Apply button shows at least 1 selection (the tactic)
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+/i })).toBeVisible();
|
||||
|
||||
// Click again to deselect
|
||||
await discoveryHeader.click();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test.fail('AC-21.6 — Apply from modal includes tactic in result (auto-save)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// KNOWN DEFECT: /api/mitre/matrix returns tactic_id as slug ("discovery") but
|
||||
// PATCH /api/simulations/:id expects TA-format ("TA0007"). When a tactic is
|
||||
// selected via the matrix modal and Apply is clicked, the PATCH body contains
|
||||
// tactic_ids: ["discovery"] which the backend rejects with "unknown tactic id".
|
||||
// Fix owner: backend-builder (matrix endpoint must return TA-format tactic IDs)
|
||||
// OR frontend-builder (modal must map slug → TA format before saving).
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 apply tactic');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for matrix to load — tactic header title uses slug: "Discovery (discovery)..."
|
||||
const discoveryBtn = dialog.locator('button[title*="discovery"]').first();
|
||||
await expect(discoveryBtn).toBeVisible({ timeout: 10_000 });
|
||||
await discoveryBtn.click();
|
||||
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+/i });
|
||||
await expect(applyBtn).toBeVisible();
|
||||
await applyBtn.click();
|
||||
|
||||
// Modal closes
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// This assertion fails because PATCH with slug ID returns 400
|
||||
await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('TA0007');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.7 — tactic chips in MitreTechniquesField
|
||||
test('AC-21.7 — tactic chips display TA-id and have × for removal', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 tactic chip');
|
||||
// Seed via API
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const tacticTag = page.getByTestId('mitre-tactic-tag');
|
||||
await expect(tacticTag).toBeVisible();
|
||||
await expect(tacticTag).toContainText('TA0007');
|
||||
|
||||
// Title attribute has id — name
|
||||
const title = await tacticTag.getAttribute('title');
|
||||
expect(title).toMatch(/TA0007/);
|
||||
expect(title).toMatch(/Discovery/);
|
||||
|
||||
// × button for removal
|
||||
await expect(page.getByRole('button', { name: /remove TA0007/i })).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.7 — removing tactic chip auto-saves', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 remove tactic');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible();
|
||||
await page.getByRole('button', { name: /remove TA0007/i }).click();
|
||||
|
||||
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId('mitre-tactic-tag')).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.7 — tactic chips visually distinct from technique chips (different class)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 style');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007'],
|
||||
technique_ids: ['T1059'],
|
||||
});
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const tacticTag = page.getByTestId('mitre-tactic-tag');
|
||||
const techTag = page.getByTestId('mitre-technique-tag');
|
||||
await expect(tacticTag).toBeVisible();
|
||||
await expect(techTag).toBeVisible();
|
||||
|
||||
// Tactic: bg-primary (filled) vs technique: bg-primary-soft
|
||||
const tacticCls = await tacticTag.getAttribute('class');
|
||||
const techCls = await techTag.getAttribute('class');
|
||||
expect(tacticCls).toMatch(/bg-primary/);
|
||||
// Technique should NOT have the solid bg-primary (just bg-primary-soft)
|
||||
expect(techCls).toMatch(/bg-primary-soft/);
|
||||
// They should differ visually
|
||||
expect(tacticCls).not.toBe(techCls);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
250
e2e/tests/us22-mitre-input-redesign.spec.ts
Normal file
250
e2e/tests/us22-mitre-input-redesign.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* US-22 — Refonte input MITRE dans le form.
|
||||
* Covers AC-22.1 → AC-22.5.
|
||||
* Key change: no "Add technique" / "Quick search" text buttons.
|
||||
* Instead: inline autocomplete input + grid icon for matrix.
|
||||
* Chips show T-id only (name in title=).
|
||||
*/
|
||||
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 = 'us22-redteam';
|
||||
const SOC_USER = 'us22-soc';
|
||||
const PASS = 'us22-pass-strong';
|
||||
|
||||
interface Simulation { id: number; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-22 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-22 — MITRE input redesign', () => {
|
||||
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-22 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 */ }
|
||||
});
|
||||
|
||||
test('AC-22.1 — layout: inline autocomplete input + matrix icon present, NO text buttons', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 layout');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Matrix icon button present
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
|
||||
// Search input placeholder visible (inline autocomplete)
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
// No old-style text buttons
|
||||
await expect(page.getByRole('button', { name: /add technique/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /quick search/i })).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.1 — inline autocomplete: click input shows combobox, type shows dropdown', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 autocomplete');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Click the fake-input placeholder to reveal the combobox
|
||||
await page.getByText(/search technique/i).click();
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
|
||||
// Type to get results
|
||||
await picker.fill('T1059');
|
||||
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Select via keyboard
|
||||
await picker.press('ArrowDown');
|
||||
await picker.press('Enter');
|
||||
|
||||
// Tag appears
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059', { timeout: 5_000 });
|
||||
// Auto-save toast
|
||||
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.1 — matrix icon opens MitreMatrixModal', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 matrix icon');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('dialog')).toContainText(/mitre att&?ck matrix/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-22.2 — chips show T-id only, name in title
|
||||
test('AC-22.2 — technique chips display T-id only, name in title= attribute', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 chip format');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const chip = page.getByTestId('mitre-technique-tag').first();
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
// Text content is T-id only
|
||||
const text = await chip.textContent();
|
||||
expect(text?.trim()).toMatch(/^T1059/);
|
||||
// Must NOT contain the full name inline
|
||||
expect(text).not.toMatch(/Command and Scripting Interpreter/);
|
||||
|
||||
// Name appears in title attribute
|
||||
const title = await chip.getAttribute('title');
|
||||
expect(title).toMatch(/T1059/);
|
||||
expect(title).toMatch(/Command and Scripting Interpreter/);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.2 — tactic chips display TA-id only, name in title= attribute', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 tactic chip format');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const chip = page.getByTestId('mitre-tactic-tag').first();
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
const text = await chip.textContent();
|
||||
expect(text?.trim()).toMatch(/^TA0007/);
|
||||
expect(text).not.toMatch(/Discovery/);
|
||||
|
||||
const title = await chip.getAttribute('title');
|
||||
expect(title).toMatch(/TA0007/);
|
||||
expect(title).toMatch(/Discovery/);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-22.4 — empty state
|
||||
test('AC-22.4 — empty state: "No techniques selected" visible, input and matrix icon still shown', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.4 empty');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Empty state message
|
||||
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||
|
||||
// Input and matrix icon still present in non-disabled mode
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-22.5 — read-only mode
|
||||
test('AC-22.5 — SOC on in_progress sim: chips visible (no ×), input + matrix icon hidden', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 soc readonly');
|
||||
// Add a technique and advance to review_required for SOC access
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
await seedTokenInStorage(context, socToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Chip is visible
|
||||
await expect(page.getByTestId('mitre-technique-tag')).toBeVisible();
|
||||
|
||||
// No × remove button (read-only for SOC on technique chips)
|
||||
await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible();
|
||||
|
||||
// Matrix icon and input hidden in disabled mode
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
||||
await expect(page.getByText(/search technique/i)).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.5 — done sim: all chips read-only, no input', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 done readonly');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
// Drive to done
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Chip visible
|
||||
await expect(page.getByTestId('mitre-technique-tag')).toBeVisible();
|
||||
// No × remove
|
||||
await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible();
|
||||
// No matrix icon
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
175
e2e/tests/us23-dark-mode.spec.ts
Normal file
175
e2e/tests/us23-dark-mode.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* US-23 — Dark mode.
|
||||
* Covers AC-23.1 (toggle in topbar), AC-23.2 (3-state cycle), AC-23.3 (localStorage persistence).
|
||||
* AC-23.4/5/6 (Tailwind tokens, component audit, screenshots) are frontend-builder scope.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us23-redteam';
|
||||
const PASS = 'us23-pass-strong';
|
||||
|
||||
test.describe('US-23 — dark mode', () => {
|
||||
let redteamToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('AC-23.1 — theme toggle button is visible in the topbar', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
// The theme toggle has aria-label containing "Theme:" and the current mode
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeBtn).toBeVisible();
|
||||
|
||||
// Shows current theme label (Light, Dark, or System)
|
||||
const label = await themeBtn.textContent();
|
||||
expect(label).toMatch(/light|dark|system/i);
|
||||
});
|
||||
|
||||
test('AC-23.2 — toggle cycles through 3 states: system → light → dark → system', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Clear any stored theme via evaluate (not addInitScript — that would fire on every load)
|
||||
await page.evaluate(() => localStorage.removeItem('mimic-theme'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeBtn).toBeVisible();
|
||||
|
||||
// Collect 4 states to confirm a full cycle
|
||||
const states: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const label = await themeBtn.textContent();
|
||||
states.push(label?.trim().toLowerCase() ?? '');
|
||||
await themeBtn.click();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Must contain all 3 modes within the cycle
|
||||
expect(states.some(s => s.includes('system'))).toBe(true);
|
||||
expect(states.some(s => s.includes('light'))).toBe(true);
|
||||
expect(states.some(s => s.includes('dark'))).toBe(true);
|
||||
// 4th state must equal 1st (full cycle completed)
|
||||
expect(states[3]).toBe(states[0]);
|
||||
});
|
||||
|
||||
test('AC-23.3 — theme persists in localStorage under "mimic-theme"', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
// Clear any stored theme via evaluate (NOT addInitScript — that runs on reload too)
|
||||
await page.evaluate(() => localStorage.removeItem('mimic-theme'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeBtn).toBeVisible();
|
||||
|
||||
// Click until we reach "dark"
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const label = await themeBtn.textContent();
|
||||
if (label?.toLowerCase().includes('dark')) break;
|
||||
await themeBtn.click();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Read localStorage — must be 'dark'
|
||||
const stored = await page.evaluate(() => localStorage.getItem('mimic-theme'));
|
||||
expect(stored).toBe('dark');
|
||||
|
||||
// Reload page — should restore dark mode (localStorage persists across reload)
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const themeAfterReload = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeAfterReload).toBeVisible();
|
||||
const labelAfterReload = await themeAfterReload.textContent();
|
||||
expect(labelAfterReload?.toLowerCase()).toContain('dark');
|
||||
|
||||
// html element should have class "dark"
|
||||
const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('AC-23.3 — default is "system" when no localStorage value', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Clear theme via evaluate then reload so page renders with no stored theme
|
||||
await page.evaluate(() => localStorage.removeItem('mimic-theme'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('mimic-theme'));
|
||||
// Default state: localStorage not yet set (null) OR set to 'system'
|
||||
expect(stored === null || stored === 'system').toBe(true);
|
||||
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
const label = await themeBtn.textContent();
|
||||
// Initial label should be System (or light if system resolves to light)
|
||||
expect(label?.toLowerCase()).toMatch(/system|light|dark/);
|
||||
});
|
||||
|
||||
test('AC-23.1 — dark mode: html has class "dark" when dark selected', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Set dark theme via evaluate then reload so React reads it on mount
|
||||
await page.evaluate(() => localStorage.setItem('mimic-theme', 'dark'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for React to apply the dark class via useEffect
|
||||
await page.waitForFunction(() => document.documentElement.classList.contains('dark'), { timeout: 3_000 });
|
||||
const hasDarkClass = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
expect(hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('AC-23.1 — light mode: html does NOT have class "dark" when light selected', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
await page.evaluate(() => localStorage.setItem('mimic-theme', 'light'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hasDarkClass = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
expect(hasDarkClass).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -198,8 +198,8 @@ test.describe('US-4 — engagement CRUD', () => {
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
|
||||
|
||||
// Redteam sees the action buttons.
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible();
|
||||
// Redteam sees the action buttons. Sprint 4: "New engagement" renamed to "New".
|
||||
await expect(page.getByRole('link', { name: /^new$/i })).toBeVisible();
|
||||
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe('US-4 — engagement CRUD', () => {
|
||||
await page.goto('/engagements');
|
||||
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
|
||||
await expect(rowAsSoc).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: /^new$/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -58,12 +58,12 @@ test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)');
|
||||
|
||||
// Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)).
|
||||
// Sprint 4: topbar utility-strip uses `bg-slab` (#111827 → rgb(17,24,39)).
|
||||
const utilityBg = await page
|
||||
.locator('div.bg-ink.text-ink-on')
|
||||
.locator('div.bg-slab.text-slab-text')
|
||||
.first()
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)');
|
||||
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(17,24,39)');
|
||||
|
||||
// Spot-check a few semantic class names live in the DOM (proves tokens are
|
||||
// wired through tailwind.config.ts and not ad-hoc hex values).
|
||||
|
||||
@@ -193,14 +193,15 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
||||
await expect(nameField).toBeEnabled();
|
||||
|
||||
// Clear the name, try to save → client validation error
|
||||
// Sprint 4: "Save Red Team" button renamed to "Save"
|
||||
await nameField.fill('');
|
||||
await page.getByRole('button', { name: /save red team/i }).click();
|
||||
await page.getByRole('button', { name: /^save$/i }).click();
|
||||
await expect(page.getByText(/name is required/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-8.6 — MITRE technique picker accessible via Quick search on the edit form', async ({
|
||||
test('AC-8.6 — MITRE technique picker accessible via inline search on the edit form', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -209,8 +210,8 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: picker opens by clicking the inline placeholder text
|
||||
await page.getByText(/search technique/i).click();
|
||||
// MitreTechniquePicker renders an input with combobox role
|
||||
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user