test: server-backed Playwright harness + /.tokens spec

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=<path> 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:<port>, 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 <img src=x onerror="window.__xss=1">
     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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-08 10:09:54 -05:00
parent ac7553f940
commit 66232598db
3 changed files with 368 additions and 1 deletions

View file

@ -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:<port>/.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',
},
],
});

166
tests/lib/server.js Normal file
View file

@ -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 <repo>/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 };

180
tests/tokens.spec.js Normal file
View file

@ -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 = `<img src=x onerror="window.__xss=1">`;
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('<img');
// The literal <img> 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('<img');
});
});