Files
verdande/web/playwright.config.js
T
Kristian WindandClaude Opus 5 fe52818ab6 feat(auth): passkeys, as a way in and as a second factor
A password and a TOTP code are both shared secrets: the server keeps something
that can be stolen from it, and the person keeps something that can be talked out
of them. A passkey is neither. The private half never leaves the device, and the
signature is bound to the origin that asked for it — so a convincing copy of the
sign-in page at another address gets a signature it cannot use.

Logging in asks for no email. The device knows which account its key belongs to,
which is not only convenience: it means the sign-in page cannot be used to find out
who has an account here. A key that verified its owner is both factors at once and
completes the login; one that only proved possession still meets the TOTP step,
exactly as a password does.

The relying party id is derived from VERDANDE_BASE_URL rather than configured
separately — a second setting that has to agree with the first is one that will
one day disagree, and the failure is a login nobody can complete. An IP address is
refused up front: no browser accepts one, and without the check the server issues
a challenge happily and the browser throws SecurityError long afterwards. The list
endpoint reports whether this deployment can offer them at all, so the interface
never shows a button that cannot work.

A challenge is stored server-side, answerable exactly once, and taken rather than
read — two requests racing the same one would both succeed, which is the shape of
a replay. A sign count that has gone backwards means the credential was cloned and
is refused rather than logged.

The Go tests cover who may reach these endpoints and that challenges are spent.
The ceremony itself needs an authenticator, so the smoke test drives Chromium's
virtual one over CDP — the only thing that can tell whether the base64url
conversions are right, since one wrong byte produces a signature that will not
verify and reads as a broken key rather than a broken encoder.

That test runs at localhost in its own context while the suite stays on the
loopback address, because WebAuthn refuses an IP and resolving localhost is slow
enough here to cost the suite minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:56:54 +02:00

139 lines
5.7 KiB
JavaScript

import { defineConfig, devices } from '@playwright/test';
import { rmSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
/**
* End-to-end smoke tests.
*
* These drive the *whole* application — the Go binary with the frontend embedded
* in it — rather than the dev server. That is deliberate: the two bugs that
* survived longest in this project were a route written outside the routes tree
* and a manifest promising files that were never generated. Neither is visible to
* a Go test, and neither is visible to a Vite build either. Only something that
* opens the real binary and clicks the real links finds them.
*
* `go run` rather than a prebuilt binary: the build cache makes the second run
* fast, and there is no separate build step to forget.
*/
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, '..');
const dataDir = join(here, '.playwright-data');
// A clean instance every run: the first test creates the first account, which only
// works on an empty database.
//
// Only in the main process. Playwright re-imports this file in every worker, and a
// wipe there deletes the database out from under the running server — which does
// not fail loudly. Connections already open keep working against the unlinked
// inode, so most requests still succeed and only the ones that need a *new*
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
if (process.env.TEST_WORKER_INDEX === undefined) {
rmSync(dataDir, { recursive: true, force: true });
}
const PORT = 8097;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: './e2e',
// One worker, and not parallel: there is one server, one database and one
// account. Parallelism here would only test whether the tests race.
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: process.env.CI ? [['github'], ['list']] : 'list',
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
// Pinned, because the app reads both. The sign-up form sends
// `navigator.language` as the account's locale — which decides whether
// "i morgen" or "tomorrow" parses — and every due date is resolved in the
// account's zone. On a CI runner in UTC with an en-US browser, an
// unpinned suite tests a different application than the one people use.
locale: 'da-DK',
timezoneId: 'Europe/Copenhagen'
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.js/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
/**
* A short WebKit pass over the forms, and nothing else.
*
* The rest of this suite is Chromium only on purpose: a smoke test exists to
* catch a build that does not work, and running it in three engines finds the
* same bug three times. Focus is the exception, and it cost a real report to
* learn it. Safari ignores the `autofocus` attribute on anything inserted
* after the page has loaded — so every inline form in this app opened with no
* cursor in it, and what you typed went nowhere. It does not look like a bug;
* it looks like the feature does not work. "Sections have no function,
* because you cannot create more than one" was this.
*
* One test, not four. The engines share this server and this database, so a
* second pass over tests that create projects by name collides with what the
* first pass already made — which fails as a name that is suddenly ambiguous
* rather than as anything about WebKit. The sections test is the one that
* earns the second engine, because it is the one the focus bug broke.
*
* WebKit here is the same engine Safari is, which is the whole point.
*/
{
name: 'webkit-forms',
grep: /@forms/,
use: { ...devices['Desktop Safari'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
}
],
webServer: {
// The whole chain, every run.
//
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
}
}
});