Files
mimic/e2e/tests/us2-login.spec.ts

159 lines
5.4 KiB
TypeScript
Raw Normal View History

feat: sprint 1 — auth + CRUD engagements Ship the first feature end-to-end on the UI: users log in with JWT, admins manage user accounts, and any authenticated user (per RBAC) can create, list, view, edit, and delete engagements. Backend (Flask + SQLAlchemy + SQLite, 63 pytest) - User / Engagement models, Alembic 0001 initial schema - argon2 password hashing, JWT bearer (60-min TTL), @login_required and @role_required decorators - 13 API endpoints under /api/*, including last-admin protection on DELETE/PATCH user and JSON 404 on unknown /api/* paths - `flask create-admin` CLI with duplicate / short-password handling Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest) - Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind - LoginPage / EngagementsList / EngagementForm / EngagementDetail / UsersAdmin pages with role-aware UI - Layout, ProtectedRoute, StatusBadge, FormField, LoadingState, ErrorState, EmptyState, Toast + provider - Axios client: Bearer interceptor, 401 → purge + /login + "Session expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate> for already-authed users, Fragment-keyed admin user rows) Deployment - Single multistage Dockerfile (node:20-alpine → python:3.12-slim) - docker/entrypoint.sh runs `flask db upgrade` before `flask run` - Makefile: build/start/stop/restart/update/logs/create-admin/ update-mitre/test-{backend,frontend,e2e}/clean - .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT - SQLite at /data/mimic.sqlite on named volume mimic-data Acceptance suite (Playwright, 36 tests, all 27 ACs) - e2e/ scaffold with playwright.config + auth/api fixtures - One spec per user story (us1-bootstrap through us6-deployment) - Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman) Docs - README.md with quick-start and architecture overview - CHANGELOG.md updated with Sprint 1 deliverables - pyrightconfig.json so the Python LSP sees backend/.venv and resolves the `backend.app.*` absolute imports Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:37:53 +02:00
/**
* 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);
});
});