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:
Knacky
2026-05-27 21:27:12 +02:00
parent e99286ef8e
commit 5aa839d105
15 changed files with 1488 additions and 55 deletions

View 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);
});
});