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>
166 lines
5.4 KiB
JavaScript
166 lines
5.4 KiB
JavaScript
// 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 };
|