/** * tokens.spec.js — server-backed Playwright tests for /.tokens. * * Starts a real zddc-server master via tests/lib/server.mjs, drives a * Chromium browser at the live URL with X-Auth-Request-Email injected * (mimicking what an authenticating reverse proxy would do upstream), * and exercises the create / list / revoke flow end-to-end through the * inline JS on the page. * * Why a server-backed harness vs file:// — /.tokens is a server-rendered * page that fetches /.api/tokens, and the JSON API authenticates via * the email middleware. Reproducing that with file:// + mocks misses * the whole contract under test. This is also the seed of a more * general "drive the master in a browser" pattern that future UI * debugging (the conflict-UI in phase 5, etc.) will reuse. */ import { test, expect } from '@playwright/test'; import { startMaster } from './lib/server.js'; test.describe('/.tokens self-service token UI', () => { let server; const TEST_EMAIL = 'alice@example.com'; test.beforeAll(async () => { server = await startMaster({ adminEmail: TEST_EMAIL }); }); test.afterAll(async () => { if (server) await server.stop(); }); // Inject X-Auth-Request-Email on every request from this Playwright // browser context. In production the upstream auth proxy adds this // after authenticating the user; in the test we set it directly so // the master sees a consistent caller without standing up a real // OIDC stack. test.use({ extraHTTPHeaders: { 'X-Auth-Request-Email': 'alice@example.com', }, }); test('anonymous → 401', async ({ request }) => { // Override the per-test header by sending without auth. const r = await request.get(`${server.baseURL}/.tokens`, { headers: { 'X-Auth-Request-Email': '' }, }); expect(r.status()).toBe(401); }); test('authenticated GET /.tokens renders the tokens table with email', async ({ page }) => { // The page now renders through the shared tables engine (header chrome // + declarative columns), not the bespoke skeleton: the title lives in // #table-title, the signed-in email in the table description, create is // the apiActions "+ New token" button, and the grid is #table-root. await page.goto(`${server.baseURL}/.tokens`); await expect(page.locator('#table-title')).toHaveText(/API tokens/i); await expect(page.locator('#table-description')).toContainText(TEST_EMAIL); await expect(page.locator('#api-create-btn')).toBeVisible(); await expect(page.locator('#table-root')).toBeVisible(); }); test('GET /.api/tokens initially returns empty list', async ({ request }) => { const r = await request.get(`${server.baseURL}/.api/tokens`); expect(r.status()).toBe(200); const list = await r.json(); expect(Array.isArray(list)).toBe(true); expect(list.filter(t => t.email === TEST_EMAIL)).toEqual([]); }); test('create token via the page → plaintext shown once → list contains it → revoke', async ({ page }) => { page.on('dialog', d => d.accept()); // auto-accept the revoke confirm() await page.goto(`${server.baseURL}/.tokens`); // Create via the apiActions "+ New token" modal. await page.locator('#api-create-btn').click(); await expect(page.locator('.api-modal')).toBeVisible(); const description = `playwright-${Date.now()}`; await page.locator('.api-modal input').first().fill(description); await page.locator('.api-modal button[type="submit"]').click(); // The plaintext token is shown exactly once, in the secret dialog. const secret = page.locator('.api-modal__secret'); await expect(secret).toBeVisible(); const plaintext = (await secret.textContent()).trim(); expect(plaintext.length).toBeGreaterThan(20); expect(plaintext).not.toContain('<'); expect(plaintext).not.toContain('"'); // Verify via the API while the dialog is up. const r = await page.request.get(`${server.baseURL}/.api/tokens`); const matches = (await r.json()).filter(t => t.description === description); expect(matches.length).toBe(1); expect(matches[0].email).toBe(TEST_EMAIL); // Done reloads; the new token appears as a row. await page.locator('.api-modal button:has-text("Done")').click(); await page.waitForLoadState('networkidle'); const row = page.locator('#table-root tbody tr', { hasText: description }); await expect(row).toBeVisible(); // Revoke via the row's button (reloads on success). await row.locator('.api-revoke').click(); await page.waitForLoadState('networkidle'); await expect(page.locator('#table-root tbody tr', { hasText: description })).toHaveCount(0); // And gone from the API list. const after = await (await page.request.get(`${server.baseURL}/.api/tokens`)).json(); expect(after.filter(t => t.description === description)).toEqual([]); }); test('plaintext token authenticates a subsequent Bearer request', async ({ request }) => { // Issue a token via the API. const created = await request.post(`${server.baseURL}/.api/tokens`, { data: { description: 'bearer-roundtrip' }, headers: { 'Content-Type': 'application/json' }, }); expect(created.status()).toBe(201); const { token } = await created.json(); // Use the bearer in a fresh request that does NOT carry the // email header — the master should accept it because the token // resolves to alice@example.com. const noEmail = await request.get(`${server.baseURL}/`, { headers: { 'X-Auth-Request-Email': '', 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }); expect(noEmail.status()).toBe(200); }); test('invalid Bearer → 401', async ({ request }) => { const r = await request.get(`${server.baseURL}/`, { headers: { 'X-Auth-Request-Email': '', 'Authorization': 'Bearer not-a-real-token', }, }); expect(r.status()).toBe(401); }); test('cross-user revoke returns 404 (no ownership leak)', async ({ request }) => { // alice creates a token. const r1 = await request.post(`${server.baseURL}/.api/tokens`, { data: { description: 'alice-only' }, headers: { 'Content-Type': 'application/json' }, }); const { id } = await r1.json(); // bob attempts to revoke. Should 404, not 403, to avoid // leaking the existence of alice's token. const r2 = await request.delete(`${server.baseURL}/.api/tokens/${id}`, { headers: { 'X-Auth-Request-Email': 'bob@example.com' }, }); expect(r2.status()).toBe(404); // alice's token should still validate. const list = await (await request.get(`${server.baseURL}/.api/tokens`)).json(); expect(list.find(t => t.id === id)).toBeTruthy(); }); test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => { const xssDesc = ``; await page.goto(`${server.baseURL}/.tokens`); await page.fill('#desc', xssDesc); await page.click('button[type="submit"]'); // Wait for the row to appear in the table. await expect(page.locator('#tokens tbody')).toContainText(' tag should NOT have been parsed as HTML — // window.__xss must remain undefined. const xssFired = await page.evaluate(() => window.__xss === 1); expect(xssFired).toBe(false); // And the on-disk text content of the cell should contain the // literal angle brackets, proving they were escaped. const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent(); expect(rowText).toContain('