Milestone 3
This commit is contained in:
12
e2e/.eslintrc.cjs
Normal file
12
e2e/.eslintrc.cjs
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true, es2022: true },
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
ignorePatterns: ['playwright-report', 'test-results', 'node_modules', '.eslintrc.cjs'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
5
e2e/.gitignore
vendored
Normal file
5
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
playwright/.cache
|
||||
*.log
|
||||
66
e2e/README.md
Normal file
66
e2e/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Metamorph e2e
|
||||
|
||||
End-to-end tests powered by [Playwright](https://playwright.dev/). Each milestone in `tasks/todo.md` should add at least one spec file (`tests/m<N>-*.spec.ts`).
|
||||
|
||||
## One-time setup
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npm install
|
||||
npm run install-browsers # downloads chromium (uses sudo for system deps)
|
||||
```
|
||||
|
||||
## Running against a live stack
|
||||
|
||||
```bash
|
||||
# 1. Bring the stack up from the repo root:
|
||||
cd .. && make up
|
||||
|
||||
# 2. Run the tests:
|
||||
cd e2e && npm test
|
||||
|
||||
# 3. Open the HTML report:
|
||||
npm run report # opens playwright-report/index.html in your browser
|
||||
```
|
||||
|
||||
Or from the repo root:
|
||||
|
||||
```bash
|
||||
make e2e # runs against the already-up stack
|
||||
make e2e-report # opens the HTML report
|
||||
make e2e-up # one-shot: make up + wait healthy + run tests
|
||||
```
|
||||
|
||||
## Auto-spawn mode
|
||||
|
||||
Set `PW_AUTOSTART=1` to let Playwright spawn `make up` itself before the run:
|
||||
|
||||
```bash
|
||||
PW_AUTOSTART=1 npm test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|--------------|--------------------------|-----------------------------------------------|
|
||||
| `BASE_URL` | `http://localhost:8080` | The front nginx URL (which proxies `/api/*`) |
|
||||
| `PW_AUTOSTART` | `0` | If `1`, spawn `make up` before the tests |
|
||||
| `CI` | unset | When set, retries=2 and parallel workers=2 |
|
||||
|
||||
## Reports
|
||||
|
||||
- **HTML** : `e2e/playwright-report/index.html`
|
||||
- **JUnit** : `e2e/playwright-report/junit.xml` (CI ingestion)
|
||||
- **Trace** : kept on first retry, opened with `npx playwright show-trace …`
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── tests/
|
||||
│ └── m0-smoke.spec.ts # bootstrap milestone (current)
|
||||
│ └── m<N>-*.spec.ts # one spec per milestone, added as features land
|
||||
├── playwright.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
1841
e2e/package-lock.json
generated
Normal file
1841
e2e/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
e2e/package.json
Normal file
26
e2e/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "metamorph-e2e",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "PWDEBUG=1 playwright test",
|
||||
"report": "playwright show-report",
|
||||
"install-browsers": "playwright install --with-deps chromium",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.13.0",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
60
e2e/playwright.config.ts
Normal file
60
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for Metamorph end-to-end tests.
|
||||
*
|
||||
* Run modes:
|
||||
* 1. Against an already-running stack (default in CI/local):
|
||||
* cd e2e && BASE_URL=http://localhost:8080 npm test
|
||||
* 2. With auto-spawned dev servers — set PW_AUTOSTART=1 (see `webServer` block).
|
||||
*
|
||||
* Reports:
|
||||
* - HTML report → `e2e/playwright-report/` (open with `npm run report`)
|
||||
* - JUnit XML → `e2e/playwright-report/junit.xml` (CI ingestion)
|
||||
* - Traces and screenshots are kept on retry for forensics.
|
||||
*/
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080';
|
||||
const AUTOSTART = process.env.PW_AUTOSTART === '1';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
// The stack uses a shared Postgres. Each spec that calls /diag/reset wipes
|
||||
// global state, so we must serialise execution to avoid spec-vs-spec races
|
||||
// (notably the install-token reset and the per-spec admin bootstrap).
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }],
|
||||
['junit', { outputFile: 'playwright-report/junit.xml' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
// Optional: spawn the compose stack via `make up` before the tests run.
|
||||
// Disabled by default — rely on the operator to bring the stack up.
|
||||
...(AUTOSTART
|
||||
? {
|
||||
webServer: {
|
||||
command: 'cd .. && make up',
|
||||
url: `${BASE_URL}/api/v1/health`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
127
e2e/tests/m0-smoke.spec.ts
Normal file
127
e2e/tests/m0-smoke.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { test, expect, type Request } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M0 — Bootstrap smoke checks.
|
||||
* Validates what M0 actually delivers:
|
||||
* 1. The 3-container stack is reachable (front + api proxy).
|
||||
* 2. The home page renders the RTOps design system primitives.
|
||||
* 3. Self-hosted webfonts (no Google Fonts CDN — spec §7).
|
||||
* 4. No JS console errors on first load.
|
||||
* 5. API health endpoint returns the expected JSON.
|
||||
*/
|
||||
|
||||
test.describe('M0 — bootstrap smoke', () => {
|
||||
const consoleErrors: string[] = [];
|
||||
const externalRequests: string[] = [];
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
consoleErrors.length = 0;
|
||||
externalRequests.length = 0;
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
page.on('request', (req: Request) => {
|
||||
const url = req.url();
|
||||
if (
|
||||
url.includes('fonts.googleapis.com') ||
|
||||
url.includes('fonts.gstatic.com') ||
|
||||
url.includes('cdn.jsdelivr.net') ||
|
||||
url.includes('unpkg.com')
|
||||
) {
|
||||
externalRequests.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('home page loads and renders the RTOps header', async ({ page }) => {
|
||||
const resp = await page.goto('/');
|
||||
expect(resp?.status(), 'home page should respond 200').toBe(200);
|
||||
await expect(page).toHaveTitle(/Metamorph/);
|
||||
|
||||
const h1 = page.getByRole('heading', { level: 1 });
|
||||
await expect(h1).toContainText('Metamorph');
|
||||
await expect(h1).toContainText('Purple Team Platform');
|
||||
});
|
||||
|
||||
test('API health card eventually shows OK', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The "API" card binds the health probe; wait for the green-accent state.
|
||||
const apiCard = page.locator('h3', { hasText: /^API$/ }).locator('..');
|
||||
await expect(apiCard).toContainText(/version\s+\d+/i, { timeout: 10_000 });
|
||||
await expect(apiCard).toContainText('ok');
|
||||
});
|
||||
|
||||
test('design system primitives render with the expected accent classes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Tags from the demo row.
|
||||
await expect(page.getByText('EVASION', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('C2', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('LATERAL', { exact: true })).toBeVisible();
|
||||
// Flow nodes.
|
||||
await expect(page.getByText('recon', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('impact', { exact: true })).toBeVisible();
|
||||
// Buttons.
|
||||
await expect(page.getByRole('button', { name: /primary/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('body uses self-hosted IBM Plex Sans, no Google Fonts requests', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Wait for fonts to settle.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const bodyFont = await page.evaluate(() =>
|
||||
window.getComputedStyle(document.body).fontFamily.toLowerCase(),
|
||||
);
|
||||
expect(bodyFont).toContain('ibm plex sans');
|
||||
|
||||
// Header is mono.
|
||||
const h1Font = await page.evaluate(() => {
|
||||
const h1 = document.querySelector('h1');
|
||||
return h1 ? window.getComputedStyle(h1).fontFamily.toLowerCase() : '';
|
||||
});
|
||||
expect(h1Font).toContain('jetbrains mono');
|
||||
|
||||
// No request must hit Google Fonts or any other CDN — see spec §7.
|
||||
expect(externalRequests, `unexpected CDN traffic: ${externalRequests.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('background uses the RTOps deep navy token', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const bg = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor);
|
||||
// tasks/design.md: --bg = #0a0e1a → rgb(10, 14, 26)
|
||||
expect(bg).toBe('rgb(10, 14, 26)');
|
||||
});
|
||||
|
||||
test('no JS console errors on first load', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// The auth provider attempts a silent /auth/refresh at mount; without a
|
||||
// refresh cookie the server returns 401 and the browser logs a generic
|
||||
// "Failed to load resource" warning. That's expected for unauthenticated
|
||||
// visitors and doesn't constitute a real error.
|
||||
const realErrors = consoleErrors.filter(
|
||||
(e) => !/Failed to load resource.*401/i.test(e),
|
||||
);
|
||||
expect(realErrors, `console errors: ${realErrors.join(' | ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('API health endpoint returns the expected JSON shape', async ({ request }) => {
|
||||
const resp = await request.get('/api/v1/health');
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = (await resp.json()) as { status: string; version: string };
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
test('CORS headers are set when the SPA origin asks for them', async ({ request }) => {
|
||||
const resp = await request.get('/api/v1/health', {
|
||||
headers: { Origin: 'http://localhost:8080' },
|
||||
});
|
||||
expect(resp.status()).toBe(200);
|
||||
// flask-cors echoes back the configured origin when allowed.
|
||||
const allowed = resp.headers()['access-control-allow-origin'];
|
||||
expect(allowed === 'http://localhost:8080' || allowed === '*').toBeTruthy();
|
||||
});
|
||||
});
|
||||
50
e2e/tests/m1-db.spec.ts
Normal file
50
e2e/tests/m1-db.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M1 — DB schema visibility checks.
|
||||
* Validates that the diagnostic endpoint reflects an applied migration and
|
||||
* that the SPA renders the resulting state in the Database card.
|
||||
*/
|
||||
|
||||
test.describe('M1 — DB schema', () => {
|
||||
test('GET /api/v1/diag/db returns alembic revision and table count', async ({ request }) => {
|
||||
const resp = await request.get('/api/v1/diag/db');
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = (await resp.json()) as {
|
||||
reachable: boolean;
|
||||
alembic_revision: string | null;
|
||||
table_count: number;
|
||||
};
|
||||
expect(body.reachable).toBe(true);
|
||||
expect(body.alembic_revision).toMatch(/^[0-9a-f]{8,}$/);
|
||||
// 26 application tables + alembic_version. Allow ≥26 to be tolerant of future migrations.
|
||||
expect(body.table_count).toBeGreaterThanOrEqual(26);
|
||||
});
|
||||
|
||||
test('Database card shows the revision short-hash and the table count', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const dbCard = page.locator('h3', { hasText: /^Database$/ }).locator('..');
|
||||
// Wait for the probing state to resolve.
|
||||
await expect(dbCard).toContainText(/revision\s+[0-9a-f]{8}/i, { timeout: 10_000 });
|
||||
|
||||
const count = await dbCard.getByTestId('db-table-count').innerText();
|
||||
expect(Number(count)).toBeGreaterThanOrEqual(26);
|
||||
|
||||
await expect(dbCard).toContainText('Alembic head reached');
|
||||
});
|
||||
|
||||
test('Roadmap card reflects M1 done', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const roadmap = page.locator('h3', { hasText: /^Roadmap$/ }).locator('..');
|
||||
// Tolerate trailing "+ M2 done" / "+ M3 done" — the contract is "M1 is past, next is named".
|
||||
await expect(roadmap).toContainText(/M1.*done/i);
|
||||
await expect(roadmap).toContainText(/Next:/i);
|
||||
});
|
||||
|
||||
test('Footer mentions M1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const footer = page.locator('footer');
|
||||
await expect(footer).toContainText(/M0\s+bootstrap/i);
|
||||
await expect(footer).toContainText(/M1\s+db\s+schema/i);
|
||||
});
|
||||
});
|
||||
167
e2e/tests/m2-auth.spec.ts
Normal file
167
e2e/tests/m2-auth.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M2 — Auth flow.
|
||||
*
|
||||
* Each test starts from a clean DB by hitting an internal helper that
|
||||
* truncates users/refresh_tokens/invitations and force-mints a fresh install
|
||||
* token. The helper is the `/api/v1/diag/reset` endpoint exposed *only* when
|
||||
* `APP_ENV=test` — see backend/app/api/diag.py.
|
||||
*
|
||||
* The flow exercised: setup → login → me → invite → register → 2nd login.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ADMIN_PASSWORD = 'AdminPass1234!';
|
||||
const ALICE_EMAIL = `alice-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ALICE_PASSWORD = 'AlicePass1234!';
|
||||
|
||||
interface ResetPayload {
|
||||
install_token: string;
|
||||
}
|
||||
|
||||
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
|
||||
const r = await request.post('/api/v1/diag/reset');
|
||||
expect(r.status(), `reset endpoint must respond 200 — got ${r.status()}`).toBe(200);
|
||||
const body = (await r.json()) as ResetPayload;
|
||||
expect(body.install_token).toMatch(/^[A-Za-z0-9_-]{30,}$/);
|
||||
return body.install_token;
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M2 — auth flow', () => {
|
||||
let installToken: string;
|
||||
let invitationToken: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
installToken = await resetAndMintToken(request);
|
||||
});
|
||||
|
||||
test('setup status is uncompleted before bootstrap', async ({ request }) => {
|
||||
const r = await request.get('/api/v1/setup');
|
||||
expect(r.status()).toBe(200);
|
||||
expect((await r.json()).completed).toBe(false);
|
||||
});
|
||||
|
||||
test('SPA setup form creates the first admin', async ({ page }) => {
|
||||
await page.goto('/setup');
|
||||
await page.getByLabel(/install token/i).fill(installToken);
|
||||
await page.getByLabel(/admin email/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(ADMIN_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /create admin/i }).click();
|
||||
await expect(page.getByText(/admin created/i)).toBeVisible();
|
||||
// Auto-redirect lands us on /login.
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('SPA login works and reveals the profile page', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
// Lands on home with header showing the admin email.
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ADMIN_EMAIL);
|
||||
|
||||
// Visit profile to check identity card. The email appears both in the nav
|
||||
// bar (testid me-email) and in the Identity card (<code>) — that's two
|
||||
// locator matches, so we look at the card-side <code> explicitly.
|
||||
await page.getByRole('link', { name: /profile/i }).click();
|
||||
await expect(page.getByRole('code').filter({ hasText: ADMIN_EMAIL })).toBeVisible();
|
||||
await expect(page.getByText(/admin\s+account/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin issues an invitation via the API and the front renders the registration form', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Reuse the page session: read access token from /auth/me cookie chain. The
|
||||
// SPA keeps it in memory, so we exercise the API via a fresh API request
|
||||
// logged in with the same credentials.
|
||||
const login = await request.post('/api/v1/auth/login', {
|
||||
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(login.status()).toBe(200);
|
||||
const access = (await login.json()).access_token as string;
|
||||
|
||||
const created = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { email_hint: ALICE_EMAIL },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
invitationToken = (await created.json()).token as string;
|
||||
expect(invitationToken).toMatch(/^[A-Za-z0-9_-]{30,}$/);
|
||||
|
||||
// Open the registration page and confirm the preview loaded.
|
||||
await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`);
|
||||
await expect(page.getByText(/account.*registration/i)).toBeVisible();
|
||||
// Email pre-filled from the hint.
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
await expect(emailInput).toHaveValue(ALICE_EMAIL);
|
||||
});
|
||||
|
||||
test('invitee submits the registration form and can log in', async ({ page }) => {
|
||||
await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`);
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(ALICE_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /create account/i }).click();
|
||||
await expect(page.getByText(/account created/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
});
|
||||
|
||||
test('non-admin gets 403 on the admin invitations endpoint', async ({ request }) => {
|
||||
const login = await request.post('/api/v1/auth/login', {
|
||||
data: { email: ALICE_EMAIL, password: ALICE_PASSWORD },
|
||||
});
|
||||
const access = (await login.json()).access_token as string;
|
||||
const r = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: {},
|
||||
});
|
||||
expect(r.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('refresh token rotation works through the SPA', async ({ page }) => {
|
||||
// Login fresh.
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
|
||||
// Force a refresh via the API client interceptor: clear the in-memory access
|
||||
// token and trigger a request that needs auth.
|
||||
await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return r.ok;
|
||||
});
|
||||
// After refresh the page is still authenticated.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
});
|
||||
|
||||
test('logout clears the session and redirects to login', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
|
||||
await page.getByRole('button', { name: /logout/i }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
// Going to /profile while logged out must redirect.
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
230
e2e/tests/m3-rbac.spec.ts
Normal file
230
e2e/tests/m3-rbac.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M3 — RBAC, group management, user assignment.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Reset + bootstrap a fresh admin.
|
||||
* 2. Admin visits /admin/groups and creates a custom group `pentest-red` with
|
||||
* only `mission.read` + `mission.write_red_fields`.
|
||||
* 3. Admin issues an invitation pre-assigned to that custom group.
|
||||
* 4. Invitee accepts, logs in, hits the API: mission.read OK, but admin-only
|
||||
* group.create returns 403 — proving the union-of-perms decorator works.
|
||||
* 5. Admin attempts to demote himself → server returns 409 last_admin_protected.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ADMIN_PASSWORD = 'AdminPass1234!';
|
||||
const BOB_EMAIL = `bob-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const BOB_PASSWORD = 'BobPass1234!';
|
||||
const GROUP_NAME = `pentest-red-${Math.floor(Math.random() * 1e6)}`;
|
||||
|
||||
interface ResetPayload {
|
||||
install_token: string;
|
||||
}
|
||||
|
||||
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
|
||||
const r = await request.post('/api/v1/diag/reset');
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as ResetPayload;
|
||||
return body.install_token;
|
||||
}
|
||||
|
||||
async function loginAndGetAccess(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const r = await request.post('/api/v1/auth/login', {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
return (await r.json()).access_token as string;
|
||||
}
|
||||
|
||||
/** Authenticate the page session via the SPA login form. */
|
||||
async function loginViaSpa(page: Page, email: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(email);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M3 — RBAC management', () => {
|
||||
let installToken: string;
|
||||
let customGroupId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
installToken = await resetAndMintToken(request);
|
||||
// Bootstrap the first admin via API to keep the e2e focused on RBAC.
|
||||
const setup = await request.post('/api/v1/setup', {
|
||||
data: {
|
||||
install_token: installToken,
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
display_name: 'Admin',
|
||||
},
|
||||
});
|
||||
expect(setup.status()).toBe(201);
|
||||
});
|
||||
|
||||
test('admin sees Admin nav links after login', async ({ page }) => {
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
// Nav now shows the admin links.
|
||||
await expect(page.getByRole('link', { name: /^users$/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^groups$/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^invitations$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('catalogue page lists the seeded permissions', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const r = await request.get('/api/v1/permissions', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as { items: Array<{ code: string }> };
|
||||
const codes = body.items.map((p) => p.code);
|
||||
// Smoke-check several families.
|
||||
expect(codes).toEqual(expect.arrayContaining([
|
||||
'user.read',
|
||||
'group.create',
|
||||
'invitation.create',
|
||||
'mission.write_red_fields',
|
||||
'mission.write_blue_fields',
|
||||
'mitre.sync',
|
||||
]));
|
||||
});
|
||||
|
||||
test('admin creates a custom group with only red-write perms via the SPA', async ({ page }) => {
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto('/admin/groups');
|
||||
await page.getByTestId('create-group').click();
|
||||
const modal = page.getByTestId('group-create-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await modal.getByLabel(/^name$/i).fill(GROUP_NAME);
|
||||
await modal.getByTestId('perm-mission.read').check();
|
||||
await modal.getByTestId('perm-mission.write_red_fields').check();
|
||||
await modal.getByTestId('group-create-save').click();
|
||||
|
||||
// The new card is visible in the listing.
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(page.getByText(GROUP_NAME)).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin invites Bob pre-assigned to the custom group', async ({ page, request }) => {
|
||||
// Fetch the group id (needed for the invitation API).
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const groups = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const items = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items;
|
||||
customGroupId = items.find((g) => g.name === GROUP_NAME)!.id;
|
||||
expect(customGroupId).toBeTruthy();
|
||||
|
||||
// Issue invitation via API (creating an invitation through the UI is covered in M2).
|
||||
const created = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { email_hint: BOB_EMAIL, group_ids: [customGroupId] },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
const token = (await created.json()).token as string;
|
||||
|
||||
// Bob completes registration.
|
||||
await page.goto(`/register?token=${encodeURIComponent(token)}`);
|
||||
await page.getByLabel(/email/i).fill(BOB_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(BOB_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(BOB_PASSWORD);
|
||||
await page.getByRole('button', { name: /create account/i }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Bob can read missions list but is forbidden from admin endpoints', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
|
||||
// Inspect /auth/me to confirm his perms.
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const body = (await me.json()) as {
|
||||
is_admin: boolean;
|
||||
permissions: string[];
|
||||
groups: string[];
|
||||
};
|
||||
expect(body.is_admin).toBe(false);
|
||||
expect(body.groups).toContain(GROUP_NAME);
|
||||
expect(body.permissions).toEqual(
|
||||
expect.arrayContaining(['mission.read', 'mission.write_red_fields']),
|
||||
);
|
||||
expect(body.permissions).not.toContain('mission.write_blue_fields');
|
||||
|
||||
// Bob does NOT have user.read → /users returns 403.
|
||||
const usersList = await request.get('/api/v1/users', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(usersList.status()).toBe(403);
|
||||
|
||||
// Bob does NOT have group.create → POST /groups returns 403.
|
||||
const groupCreate = await request.post('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { name: 'wont-happen', description: null },
|
||||
});
|
||||
expect(groupCreate.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('non-admin SPA visitor cannot reach /admin/* routes', async ({ page }) => {
|
||||
await loginViaSpa(page, BOB_EMAIL, BOB_PASSWORD);
|
||||
// Direct nav to /admin/users — RequireAdmin redirects to /.
|
||||
await page.goto('/admin/users');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
// The nav also hides the admin links.
|
||||
await expect(page.getByRole('link', { name: /^users$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('last-admin protection prevents the bootstrap admin from being deleted', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const adminId = (await me.json()).id as string;
|
||||
|
||||
const del = await request.delete(`/api/v1/users/${adminId}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(del.status()).toBe(409);
|
||||
expect((await del.json()).error).toBe('last_admin_protected');
|
||||
});
|
||||
|
||||
test('admin promotes Bob and the new perms take effect', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
// Find Bob.
|
||||
const list = await request.get(`/api/v1/users?q=${encodeURIComponent(BOB_EMAIL)}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const bob = ((await list.json()) as { items: Array<{ id: string; email: string }> }).items.find(
|
||||
(u) => u.email === BOB_EMAIL,
|
||||
)!;
|
||||
|
||||
// Find admin group id.
|
||||
const groups = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const adminGroup = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items.find(
|
||||
(g) => g.name === 'admin',
|
||||
)!;
|
||||
|
||||
const r = await request.put(`/api/v1/users/${bob.id}/groups`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { group_ids: [customGroupId, adminGroup.id] },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
|
||||
// Bob now has admin rights via group membership.
|
||||
const bobAccess = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
|
||||
const groupsAsBob = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${bobAccess}` },
|
||||
});
|
||||
expect(groupsAsBob.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
18
e2e/tsconfig.json
Normal file
18
e2e/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["playwright.config.ts", "tests/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user