diff --git a/playwright.config.js b/playwright.config.js index e8b6eb6..94196ff 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -2,7 +2,11 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', - timeout: 30000, + // tokens.spec.js builds the Go binary on first run via podman + waits + // for the spawned master to listen — both can take longer than the + // default 30s on a cold cache. Other specs are file:// driven and + // unaffected by this bump. + timeout: 60000, retries: 0, reporter: [['line'], ['html', { open: 'never' }]], @@ -71,6 +75,23 @@ export default defineConfig({ name: 'schema', testMatch: 'schema.spec.js', }, + { + // Server-backed: starts a real zddc-server master via + // tests/lib/server.mjs (which builds the binary on first run + // through the canonical podman/zddc-go:1.24 invocation), drives + // Chromium against http://127.0.0.1:/.tokens, exercises + // create/list/revoke + bearer round-trip + cross-user 404 + + // XSS-guard. The binary build is cached at zddc/dist/zddc-server- + // test and invalidated by a hash of cmd/+internal/+go.{mod,sum} + // so a second run only takes the master-startup time (~1s). + // First run takes ~30s for the build. + // + // The lifecycle is per-spec via beforeAll/afterAll — Playwright's + // top-level webServer hook would fire for every project, including + // the file://-driven tool tests that don't need the server. + name: 'tokens', + testMatch: 'tokens.spec.js', + }, ], }); diff --git a/tests/lib/server.js b/tests/lib/server.js new file mode 100644 index 0000000..055a2fe --- /dev/null +++ b/tests/lib/server.js @@ -0,0 +1,166 @@ +// server.js — fixture for tests that need a running zddc-server. +// +// Exported `startMaster()` builds the binary if needed (via the canonical +// podman/zddc-go:1.24 invocation from AGENTS.md), seeds a minimal master +// root in a tempdir, spawns the binary on a free port, and resolves once +// it's listening. Returns { baseURL, root, stop() }. +// +// The build step is skipped when ZDDC_TEST_BIN is set in the environment; +// otherwise the binary is cached at /zddc/dist/zddc-server-test +// with a sibling .hash file holding the SHA256 of the source tree +// (cmd/+internal/+go.{mod,sum}). A second test run reuses the cache when +// the hash is unchanged. +// +// CommonJS form because Playwright's loader handles top-level ESM +// `import` in *.spec.js but doesn't transform the .js helpers we ship +// alongside; mixing leads to "exports is not defined in ES module +// scope" at the helper's first line. Spec files use `import { ... } +// from './lib/server.js'`; the import resolves through CommonJS interop. + +const { spawn, execFileSync } = require('child_process'); +const fs = require('fs'); +const { tmpdir } = require('os'); +const { join, resolve, dirname } = require('path'); +const { createHash } = require('crypto'); +const net = require('net'); + +const REPO_ROOT = resolve(__dirname, '..', '..'); + +async function freePort() { + return await new Promise((res, rej) => { + const srv = net.createServer(); + srv.unref(); + srv.on('error', rej); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => res(port)); + }); + }); +} + +function sourceHash() { + const dirs = [join(REPO_ROOT, 'zddc', 'cmd'), join(REPO_ROOT, 'zddc', 'internal')]; + const files = [join(REPO_ROOT, 'zddc', 'go.mod'), join(REPO_ROOT, 'zddc', 'go.sum')]; + const h = createHash('sha256'); + function visit(p) { + const s = fs.statSync(p); + if (s.isDirectory()) { + for (const e of fs.readdirSync(p).sort()) visit(join(p, e)); + } else { + h.update(p); + h.update(fs.readFileSync(p)); + } + } + for (const d of dirs) if (fs.existsSync(d)) visit(d); + for (const f of files) if (fs.existsSync(f)) visit(f); + return h.digest('hex'); +} + +function ensureBinary() { + if (process.env.ZDDC_TEST_BIN) return process.env.ZDDC_TEST_BIN; + + const binDir = join(REPO_ROOT, 'zddc', 'dist'); + const binPath = join(binDir, 'zddc-server-test'); + const stampPath = join(binDir, 'zddc-server-test.hash'); + const wantHash = sourceHash(); + + if (fs.existsSync(binPath) && fs.existsSync(stampPath)) { + const have = fs.readFileSync(stampPath, 'utf8').trim(); + if (have === wantHash) return binPath; + } + + fs.mkdirSync(binDir, { recursive: true }); + const args = [ + 'run', '--rm', '--network=host', + '-v', `${REPO_ROOT}:/src:Z`, + '-v', `/tmp/gocache:/root/go/pkg/mod:Z`, + '-v', `${binDir}:/out:Z`, + '-w', '/src/zddc', + '-e', 'GOPROXY=https://proxy.golang.org', + '-e', 'GOSUMDB=off', + '-e', 'GOPRIVATE=', + 'localhost/zddc-go:1.24', + 'go', 'build', '-o', '/out/zddc-server-test', './cmd/zddc-server', + ]; + execFileSync('podman', args, { stdio: 'inherit' }); + fs.writeFileSync(stampPath, wantHash); + return binPath; +} + +async function waitListening(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await fetch(url, { method: 'GET' }); + if (r.status >= 200 && r.status < 500) return; + } catch { + // not listening yet + } + await new Promise(r => setTimeout(r, 100)); + } + throw new Error(`server did not start listening at ${url} within ${timeoutMs}ms`); +} + +async function startMaster(opts = {}) { + const adminEmail = opts.adminEmail || 'alice@example.com'; + const noAuth = opts.noAuth === true; + + const root = fs.mkdtempSync(join(tmpdir(), 'zddc-test-')); + fs.writeFileSync(join(root, '.zddc'), [ + `title: "Playwright test fixture"`, + `admins:`, + ` - ${adminEmail}`, + `acl:`, + ` permissions:`, + ` "${adminEmail}": rwcda`, + ` "*@example.com": r`, + '', + ].join('\n')); + + const bin = ensureBinary(); + const port = await freePort(); + const baseURL = `http://127.0.0.1:${port}`; + + const args = [ + '--root', root, + '--addr', `127.0.0.1:${port}`, + '--tls-cert=none', + ]; + if (noAuth) args.push('--no-auth'); + + const proc = spawn(bin, args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }); + + const buf = []; + proc.stdout.on('data', d => buf.push(d.toString())); + proc.stderr.on('data', d => buf.push(d.toString())); + + let exited = false; + proc.on('exit', () => { exited = true; }); + + try { + await waitListening(`${baseURL}/`, 10000); + } catch (e) { + proc.kill('SIGTERM'); + throw new Error(`startMaster failed: ${e.message}\nServer logs:\n${buf.join('')}`); + } + + return { + baseURL, + root, + proc, + logs: () => buf.join(''), + async stop() { + if (exited) return; + proc.kill('SIGTERM'); + await new Promise(res => { + const t = setTimeout(() => { proc.kill('SIGKILL'); res(); }, 3000); + proc.on('exit', () => { clearTimeout(t); res(); }); + }); + }, + }; +} + +module.exports = { startMaster }; diff --git a/tests/tokens.spec.js b/tests/tokens.spec.js new file mode 100644 index 0000000..8ef6b3d --- /dev/null +++ b/tests/tokens.spec.js @@ -0,0 +1,180 @@ +/** + * 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 page with email', async ({ page }) => { + await page.goto(`${server.baseURL}/.tokens`); + await expect(page.locator('h1')).toHaveText(/API tokens/i); + await expect(page.locator('.who')).toContainText(TEST_EMAIL); + await expect(page.locator('#create')).toBeVisible(); + await expect(page.locator('#tokens')).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 the new entry', async ({ page }) => { + await page.goto(`${server.baseURL}/.tokens`); + + // Wait for the inline JS's initial refresh() so we know the + // table is populated (or shows "No tokens issued yet."). + await expect(page.locator('#tokens tbody')).not.toBeEmpty(); + + // Fill the form and submit. + const description = `playwright-${Date.now()}`; + await page.fill('#desc', description); + await page.click('button[type="submit"]'); + + // The plaintext token appears in #created div.token-secret — + // shown exactly once per the API contract. + const secret = page.locator('#created .token-secret'); + await expect(secret).toBeVisible(); + const plaintext = (await secret.textContent()).trim(); + expect(plaintext.length).toBeGreaterThan(20); + expect(plaintext).not.toContain('<'); + expect(plaintext).not.toContain('"'); + + // The token appears in the table. + const row = page.locator('#tokens tbody tr', { hasText: description }); + await expect(row).toBeVisible(); + + // Verify via the API too — the listed token's description matches. + const r = await page.request.get(`${server.baseURL}/.api/tokens`); + const list = await r.json(); + const matches = list.filter(t => t.description === description); + expect(matches.length).toBe(1); + expect(matches[0].email).toBe(TEST_EMAIL); + + // Revoke via the row's button. The page's confirm() dialog needs + // to be auto-accepted. + page.on('dialog', d => d.accept()); + await row.locator('button.danger').click(); + + // Token should disappear from the table. + await expect(page.locator('#tokens tbody tr', { hasText: description })).toHaveCount(0); + + // And 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('