/** * US-2 — login / logout / session expiry. * * Covers AC-2.1 through AC-2.6. Mix of pure-API assertions (token shape + * generic 401) and full UI flow (form submit, redirect, expired-token toast). */ import { test, expect } from '@playwright/test'; import { adminToken, ensureUser, deleteUserByUsername, makeClient } from '../fixtures/api'; import { clearAuthStorage, seedTokenInStorage } from '../fixtures/auth'; const USERNAME = 'us2-loginuser'; const PASSWORD = 'us2-pass8chars'; test.describe('US-2 — auth flow', () => { test.beforeAll(async () => { await ensureUser(USERNAME, PASSWORD, 'redteam'); }); test.afterAll(async () => { try { const token = await adminToken(); await deleteUserByUsername(token, USERNAME); } catch { /* noop */ } }); test('AC-2.1 — POST /api/auth/login returns access_token + user payload', async () => { const client = makeClient(); const r = await client.post('/auth/login', { username: USERNAME, password: PASSWORD, }); expect(r.status).toBe(200); expect(typeof r.data.access_token).toBe('string'); expect(r.data.access_token.length).toBeGreaterThan(20); expect(r.data.user).toMatchObject({ username: USERNAME, role: 'redteam', }); expect(typeof r.data.user.id).toBe('number'); }); test('AC-2.2 — bad credentials return 401 with generic error (no username/password leak)', async () => { const client = makeClient(); const wrongPass = await client.post('/auth/login', { username: USERNAME, password: 'nope-not-the-password', }); expect(wrongPass.status).toBe(401); expect(wrongPass.data.error).toBe('Invalid credentials'); const noUser = await client.post('/auth/login', { username: 'does-not-exist-anywhere', password: 'whatever12345', }); expect(noUser.status).toBe(401); expect(noUser.data.error).toBe('Invalid credentials'); // Critical: same message in both cases — no enumeration of users. expect(noUser.data.error).toBe(wrongPass.data.error); }); test('AC-2.3 — logout: client-side token purge (UI clears localStorage)', async ({ page, context, }) => { await page.goto('/login'); await page.fill('input[name="username"]', USERNAME); await page.fill('input[name="password"]', PASSWORD); await page.click('button[type="submit"]'); await page.waitForURL(/\/engagements\b/); // Token must now exist in localStorage. const before = await page.evaluate(() => window.localStorage.getItem('mimic.token')); expect(before).toBeTruthy(); // Click "Sign out" (Layout topbar). await page.getByRole('button', { name: /sign out/i }).click(); await page.waitForURL(/\/login\b/); const after = await page.evaluate(() => window.localStorage.getItem('mimic.token')); expect(after).toBeNull(); }); test('AC-2.4 — /login form: success → /engagements, failure → visible error message', async ({ page, }) => { await page.goto('/login'); await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible(); // Failure path first. await page.fill('input[name="username"]', USERNAME); await page.fill('input[name="password"]', 'wrongpassword'); await page.click('button[type="submit"]'); const errorLocator = page.getByTestId('login-error'); await expect(errorLocator).toBeVisible(); await expect(errorLocator).toHaveText(/invalid credentials/i); // Now success. await page.fill('input[name="password"]', PASSWORD); await page.click('button[type="submit"]'); await page.waitForURL(/\/engagements\b/); await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible(); }); test('AC-2.5 — navigating to /engagements without a token redirects to /login', async ({ page, context, }) => { await clearAuthStorage(context); await context.clearCookies(); await page.goto('/engagements'); await page.waitForURL(/\/login\b/); await expect(page.getByRole('heading', { name: /mimic/i })).toBeVisible(); }); test('AC-2.6 — 401 from API purges token + redirects to /login with "Session expirée" toast', async ({ page, context, }) => { // Seed a clearly-invalid token so the very first API call (from /me on // app bootstrap) returns 401, tripping the axios interceptor. await seedTokenInStorage(context, 'totally.invalid.jwt'); // Race-safe toast capture: poll the DOM while React renders + the // interceptor's window.location.assign('/login') tears the page down. let sawToast = false; const stopWatching = page .waitForSelector('[data-testid="toast"]:has-text("Session expir")', { state: 'visible', timeout: 8_000, }) .then(() => { sawToast = true; }) .catch(() => { /* didn't catch the toast in time — assertion below will flag */ }); await page.goto('/engagements'); // Interceptor should boot us to /login. await page.waitForURL(/\/login\b/, { timeout: 10_000 }); // Storage must be purged. Use waitForFunction to dodge the navigation race. await page.waitForFunction(() => window.localStorage.getItem('mimic.token') === null, { timeout: 5_000, }); await stopWatching; expect( sawToast, 'expected "Session expirée" toast to be visible at some point during the 401 redirect', ).toBe(true); }); });