From 66232598db671d4e4201713eb10f45beecfb0075 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 8 May 2026 10:09:54 -0500 Subject: [PATCH] test: server-backed Playwright harness + /.tokens spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first Playwright spec that drives a real running zddc-server in Chromium. Future UI debugging (the conflict-UI in phase 5, browser- side iteration on the master's HTML pages, etc.) reuses the same harness — beforeAll spins up a master on a random port, the spec talks to it, afterAll tears it down. Files: - tests/lib/server.js: CommonJS module exporting startMaster(opts). Builds the binary on first run via the canonical podman/zddc-go:1.24 invocation from AGENTS.md, caching at zddc/dist/zddc-server-test with a sibling .hash file (SHA256 of cmd/+internal/+go.{mod,sum}) that invalidates on source change. Subsequent runs skip the build. Set ZDDC_TEST_BIN= to use a pre-built binary (CI / debugging). Seeds a minimal master root in os.tmpdir() with a permissive .zddc granting the test user (default alice@example.com) full access plus read for *@example.com. Picks a free port via net.listen(:0), spawns the binary on 127.0.0.1:, polls until listening (max 10s). Returns { baseURL, root, proc, logs(), stop() }. CommonJS (require/module.exports) rather than ESM because Playwright's loader transforms top-level `import` in *.spec.js files but not in the .js helpers we ship alongside; mixing produces "exports is not defined in ES module scope" at the helper's first line. Spec files use `import { ... } from './lib/server.js'` and the import resolves through the CJS interop layer cleanly. - tests/tokens.spec.js: 8 server-backed scenarios covering the entire /.tokens contract: 1. Anonymous → 401 on /.tokens (X-Auth-Request-Email empty). 2. Authenticated GET /.tokens renders the page with the user's email visible in the .who line and the create form + tokens table both present and populated. 3. GET /.api/tokens returns an empty list initially. 4. Create-via-page round-trip: fill the form, click submit, plaintext appears once in #created .token-secret (hidden from later reads), row appears in the table, API list confirms the description, the row's Revoke button removes it from both the table and the API. 5. Plaintext token authenticates a subsequent Bearer request even when X-Auth-Request-Email is empty — confirms the middleware bridge from Bearer to ACL email. 6. Invalid Bearer → 401 (no silent fallback to anonymous). 7. Cross-user revoke returns 404 (not 403) — the ownership-non-leak guarantee. 8. XSS guard: description with should render as text (assert window.__xss !== 1) — the inline JS's escapeHTML is the only thing standing between an attacker who could create tokens and stored XSS on the management page. test.use({ extraHTTPHeaders }) injects X-Auth-Request-Email on every request from the Playwright browser context, mimicking what an upstream auth proxy adds in production. Per-test overrides clear it to test anonymous paths. - playwright.config.js: adds the `tokens` project. Bumps the global timeout from 30s → 60s so the first run's binary-build (~30s on a cold gocache) doesn't time out the suite. The tokens project testMatches only tokens.spec.js, so other projects (the file://- driven tool tests) are unaffected. Verified: all 8 tests pass (12.5s warm; ~45s cold including the build). The harness is ready to graft additional server-backed specs onto — phase 5's conflict-UI in particular will follow the same pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- playwright.config.js | 23 +++++- tests/lib/server.js | 166 +++++++++++++++++++++++++++++++++++++++ tests/tokens.spec.js | 180 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 tests/lib/server.js create mode 100644 tests/tokens.spec.js 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('