MultiSite: one instance serves authentication across many domains (#4)

- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-07 22:02:06 +00:00
parent 3912b5473e
commit 10af29f92d
91 changed files with 5755 additions and 1867 deletions
+18 -7
View File
@@ -75,18 +75,22 @@ Runs tests with Playwright Inspector for step-by-step debugging.
```
e2e/
├── playwright.config.ts # Playwright configuration
├── playwright.config.js # Playwright configuration
├── package.json
├── tsconfig.json
├── test-data/ # Test database (created at runtime)
│ └── test.sqlite
│ └── paskia.kantadb
└── tests/
├── global-setup.ts # Creates fresh DB, captures reset token
├── global-setup.ts # Creates fresh DB (localhost + test.localhost domains), captures reset token
├── global-teardown.ts # Cleanup
├── passkey.spec.ts # Main E2E tests
├── 10-passkey.spec.ts # Registration, authentication, session tests
├── 20-api-auth.spec.ts # API-mode iframe flows (401/403/reauth)
├── 50-multidomain.spec.ts# Multi-domain dispatch, related origins, auth hosts, remote login
├── 99-logout.spec.ts # Logout (runs last)
└── fixtures/
├── virtual-authenticator.ts # Virtual authenticator setup
── passkey-helpers.ts # WebSocket helpers
── passkey-helpers.ts # WebSocket helpers
└── remote-auth.ts # Pairing-code remote auth helpers
```
## What's Tested
@@ -107,6 +111,13 @@ e2e/
- Logout (`/auth/api/logout`)
- Invalid/missing token rejection
### Multi-Domain
- Host-based domain dispatch (`localhost` vs `test.localhost`, 421 for unknown hosts)
- Related Origin Requests well-known endpoint and admin domain API
- Per-domain auth hosts (UI at the site root)
- WebSocket cross-domain rules
- Cross-domain remote login via pairing code
## How Virtual Authenticator Works
The tests use Chrome DevTools Protocol (CDP) to create a virtual authenticator:
@@ -142,12 +153,12 @@ This creates an in-browser authenticator that:
## Limitations
1. **Chromium only**: Virtual authenticator is a Chrome DevTools feature
2. **No cross-origin**: Tests run on localhost; production-like origins need additional setup
2. **Multi-domain via `*.localhost`**: Chrome resolves any `*.localhost` hostname to loopback, which the tests use for cross-domain scenarios; non-localhost domains are exercised only via explicit Host headers (Node-side requests)
3. **Single user per run**: Bootstrap creates one admin user; additional users need admin API
## Debugging Tips
1. **Check test database**: `e2e/test-data/test.sqlite` persists after tests
1. **Check test database**: `e2e/test-data/paskia.kantadb` is removed during teardown; comment out the cleanup in `global-teardown.ts` to inspect it after a run
2. **View server output**: Global setup echoes server bootstrap to console
3. **Use trace viewer**: `npx playwright show-trace` on failure traces
+258
View File
@@ -0,0 +1,258 @@
import { test, expect } from './fixtures/virtual-authenticator'
import {
registerPasskey,
getSessionCookieName,
popDeviceToken,
} from './fixtures/passkey-helpers'
import {
startRemoteAuthRequest,
awaitRemoteAuthSession,
permitRemoteAuth,
} from './fixtures/remote-auth'
/**
* Multi-domain E2E tests.
*
* The server is bootstrapped with two domains: localhost (default) and
* test.localhost. Chrome resolves any *.localhost hostname to loopback, so
* both domains are reachable over real HTTP from the browser.
*
* Covers:
* - Host-based domain dispatch (settings, 421 for unknown hosts)
* - Related Origin Requests well-known endpoint + admin domain API,
* including HTTP dispatch to a related hostname
* - Per-domain auth hosts: settings, UI at the site root, /auth/ redirect
* - WebSocket cross-domain rule: rejected unless the Host is the origin
* domain's own auth host
* - Cross-domain remote login: a passkey registered on localhost permits a
* session on test.localhost via pairing code
* - The profile enrollment prompt on a domain where the user has no passkey
*/
test.describe('Multi-domain E2E', () => {
test.describe.configure({ mode: 'serial' })
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
const domainUrl = 'http://test.localhost:4404'
test('dispatches domains by host header', async ({ page }) => {
// Browser navigation: Chrome maps *.localhost to loopback
const domainResp = await page.goto(`${domainUrl}/auth/api/settings`)
expect(domainResp?.status()).toBe(200)
const domainSettings = await domainResp?.json()
expect(domainSettings.rp_id).toBe('test.localhost')
expect(domainSettings.own_auth_host).toBeNull()
expect(domainSettings.auth_host).toBeNull()
expect(domainSettings.ui_base_path).toBe('/auth/')
const defaultResp = await page.goto(`${baseUrl}/auth/api/settings`)
expect(defaultResp?.status()).toBe(200)
const defaultSettings = await defaultResp?.json()
expect(defaultSettings.rp_id).toBe('localhost')
expect(defaultSettings.auth_host).toBeNull()
expect(defaultSettings.ui_base_path).toBe('/auth/')
// Unknown host is rejected with 421 Misdirected Request.
// page.request is Node-side, so target loopback with an explicit Host.
const unknownResp = await page.request.get(`${baseUrl}/auth/api/settings`, {
headers: { Host: 'unknown.example.org' },
})
expect(unknownResp.status()).toBe(421)
})
test('well-known webauthn endpoint reflects related origins', async ({ page }) => {
// No related origins configured initially → 404
const before = await page.request.get(`${baseUrl}/.well-known/webauthn`)
expect(before.status()).toBe(404)
})
test('master admin manages domains and related origins via API', async ({ page, virtualAuthenticator }) => {
// Fresh session via device token (domain writes require recent auth)
const deviceToken = popDeviceToken()
test.skip(!deviceToken, 'No device tokens available')
await page.goto('/auth/')
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
expect(reg.session_token).toBeTruthy()
const headers = { Cookie: `${getSessionCookieName()}=${reg.session_token}` }
// List domains
const list = await page.request.get(`${baseUrl}/auth/api/admin/domains/`, { headers })
expect(list.ok()).toBeTruthy()
const domains = await list.json()
expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost'])
const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost')
expect(localhostDomain.origins).toEqual({ '**.localhost': true })
// Add a related origin (unrelated domain) to the localhost domain —
// same origins table; classification is derived from the rp-id
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers,
data: { rp_name: '', origins: { '**.localhost': true, 'app.example.com': true } },
})
expect(patch.ok()).toBeTruthy()
// The well-known endpoint now lists it
const wk = await page.request.get(`${baseUrl}/.well-known/webauthn`)
expect(wk.ok()).toBeTruthy()
const wkJson = await wk.json()
expect(wkJson.origins).toContain('https://app.example.com')
// The related hostname now dispatches to the listing domain (HTTP).
// page.request is Node-side, so target loopback with an explicit Host.
const relResp = await page.request.get(`${baseUrl}/auth/api/settings`, {
headers: { Host: 'app.example.com' },
})
expect(relResp.ok()).toBeTruthy()
expect((await relResp.json()).rp_id).toBe('localhost')
// Restore: back to the pristine seeded state for later tests
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers,
data: { rp_name: '', origins: { '**.localhost': true } },
})
expect(restore.ok()).toBeTruthy()
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
expect(after.status()).toBe(404)
// ...and the related hostname is unknown again
const relGone = await page.request.get(`${baseUrl}/auth/api/settings`, {
headers: { Host: 'app.example.com' },
})
expect(relGone.status()).toBe(421)
})
test('per-domain auth host serves the domain UI at its site root', async ({ page, virtualAuthenticator }) => {
// Fresh session via device token (domain writes require recent auth)
const deviceToken = popDeviceToken()
test.skip(!deviceToken, 'No device tokens available')
await page.goto('/auth/')
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
expect(reg.session_token).toBeTruthy()
const headers = { Cookie: `${getSessionCookieName()}=${reg.session_token}` }
const authHost = 'auth.test.localhost:4404'
try {
// Mark an auth host on the test.localhost domain. Chrome resolves any
// *.localhost hostname to loopback, so the auth host is reachable.
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
headers,
data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '**.test.localhost': true } },
})
expect(patch.ok()).toBeTruthy()
// The auth host dispatches to its domain and reports itself in settings
const settingsResp = await page.goto(`http://${authHost}/auth/api/settings`)
expect(settingsResp?.status()).toBe(200)
const settings = await settingsResp?.json()
expect(settings.rp_id).toBe('test.localhost')
expect(settings.auth_host).toBe(authHost)
expect(settings.own_auth_host).toBe(authHost)
expect(settings.ui_base_path).toBe('/')
// The UI lives at the site root on the auth host
const rootResp = await page.goto(`http://${authHost}/`)
expect(rootResp?.status()).toBe(200)
expect(rootResp?.headers()['content-type']).toContain('text/html')
// /auth/ on the auth host redirects to the root
const redir = await page.request.get(`${baseUrl}/auth/`, {
headers: { Host: authHost },
maxRedirects: 0,
})
expect(redir.status()).toBe(307)
expect(redir.headers()['location']).toMatch(/^http:\/\/auth\.test\.localhost(:\d+)?\/$/)
} finally {
// Restore: back to the pristine seeded state (later tests sign in on
// test.localhost, and an empty table would allow nothing)
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
headers,
data: { rp_name: '', origins: { '**.test.localhost': true } },
})
expect(restore.ok()).toBeTruthy()
}
const after = await page.request.get(`${baseUrl}/auth/api/settings`, {
headers: { Host: 'test.localhost:4404' },
})
expect((await after.json()).auth_host).toBeNull()
})
test('WebSocket cross-domain connections require the origin domain\'s own auth host', async ({ page }) => {
await page.goto(`${domainUrl}/auth/`)
// Same-domain WebSocket receives authentication options...
const sameDomain: any = await page.evaluate(async () => {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://${location.host}/auth/ws/authenticate`)
const timer = setTimeout(() => { ws.close(); resolve({ message: false }) }, 5000)
ws.onmessage = () => { clearTimeout(timer); ws.close(); resolve({ message: true }) }
ws.onerror = () => { clearTimeout(timer); resolve({ message: false }) }
})
})
expect(sameDomain.message).toBe(true)
// ...but a cross-domain connection is closed pre-accept: test.localhost
// has no auth host of its own, so no other host may serve its logins
const crossDomain: any = await page.evaluate(async (host) => {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://${host}/auth/ws/authenticate`)
let message = false
const timer = setTimeout(() => { ws.close(); resolve({ message, code: -1 }) }, 5000)
ws.onmessage = () => { message = true }
ws.onclose = (event) => {
clearTimeout(timer)
resolve({ message, code: event.code, wasClean: event.wasClean })
}
})
}, new URL(baseUrl).host)
expect(crossDomain.message).toBe(false)
expect(crossDomain.wasClean).toBe(false)
})
test('cross-domain remote login via pairing code', async ({ page, virtualAuthenticator }) => {
// Register a fresh passkey on localhost (this test's virtual authenticator)
const deviceToken = popDeviceToken()
test.skip(!deviceToken, 'No device tokens available')
await page.goto('/auth/')
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
expect(reg.session_token).toBeTruthy()
// Requester page on the other domain (no session there)
const reqPage = await page.context().newPage()
await reqPage.goto(`${domainUrl}/auth/`)
const pairingCode = await startRemoteAuthRequest(reqPage)
expect(pairingCode.split('.')).toHaveLength(3)
// Approver permits with the localhost passkey; the "found" message names
// the requesting domain
const found = await permitRemoteAuth(page, pairingCode)
expect(found.rp_id).toBe('test.localhost')
// The requester redeems the exchange code on its own domain and the
// session validates there for the same user
const validation = await awaitRemoteAuthSession(reqPage)
expect(validation.ctx.user.uuid).toBe(reg.user)
// The session is recorded with the requesting host
const userInfo = await reqPage.evaluate(async () => {
const resp = await fetch('/auth/api/user-info')
if (!resp.ok) throw new Error(`user-info failed: ${resp.status}`)
return resp.json()
})
const current = Object.values(userInfo.sessions as any[]).find((s: any) => s.is_current) as any
expect(current.host).toContain('test.localhost')
// The profile on test.localhost prompts adding a passkey for this domain,
// and the existing localhost passkey carries a domain badge
await reqPage.goto(`${domainUrl}/auth/`)
const notice = reqPage.locator('.domain-enroll-notice')
await expect(notice).toBeVisible({ timeout: 15000 })
await expect(notice).toContainText('test.localhost')
await expect(reqPage.locator('.badge-domain').first()).toHaveText('localhost')
await reqPage.close()
})
})
+187
View File
@@ -0,0 +1,187 @@
import { type Page } from '@playwright/test'
/**
* Remote authentication (pairing code) helpers for E2E tests.
* These drive the /auth/ws/remote-auth/* protocol directly in browser context,
* so requests carry the page origin's cookies and Chrome's host resolution.
*/
// PBKDF2-SHA512 PoW solver; must match frontend/src/utils/pow.js.
// Passed as source into page.evaluate and instantiated with eval there.
const solvePoWSource = `async (challengeBytes, work) => {
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
const solution = new Uint8Array(8 * work)
const nonce = new Uint32Array(2)
const mask = 0x7FF
for (let i = 0; i < work; i++) {
let result
do {
if (++nonce[0] === 0x100000000) ++nonce[1]
result = new Uint32Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512' }, baseKey, 32))
} while (result[0] & mask)
solution.set(new Uint8Array(nonce.buffer), i * 8)
}
return solution
}`
const b64helpersSource = `
const b64dec = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
const b64enc = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
`
/**
* Start a remote auth request on the given page (the device wanting to log in).
* The page must already be navigated to the requesting domain's origin.
* Keeps the WebSocket open on window.__raWs and collects later messages into
* window.__raMsgs; resolves with the pairing code.
*/
export async function startRemoteAuthRequest(page: Page): Promise<string> {
return page.evaluate(async ({ powSrc, b64src }) => {
const solvePoW = eval(`(${powSrc})`)
const { b64dec, b64enc } = eval(`(() => { ${b64src}; return { b64dec, b64enc } })()`)
const w = window as any
w.__raMsgs = []
return new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://${location.host}/auth/ws/remote-auth/request`)
w.__raWs = ws
ws.onmessage = async (event) => {
const data = JSON.parse(event.data)
w.__raMsgs.push(data)
if (typeof data.status === 'number' && data.status >= 400) {
ws.close()
reject(new Error(data.detail || `request failed: ${data.status}`))
return
}
if (data.pow && !data.pairing_code) {
const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work)
ws.send(JSON.stringify({ pow: b64enc(solution), action: 'login' }))
return
}
if (data.pairing_code) {
resolve(data.pairing_code)
}
}
ws.onerror = () => reject(new Error('WebSocket error during remote auth request'))
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
})
}, { powSrc: solvePoWSource, b64src: b64helpersSource })
}
/**
* Wait for the remote auth request on the page to complete, redeem the
* exchange code via set-session, and return the /auth/api/validate response.
*/
export async function awaitRemoteAuthSession(page: Page, timeoutMs = 90000): Promise<any> {
return page.evaluate(async ({ timeoutMs }) => {
const w = window as any
const msgs: any[] = w.__raMsgs
if (!msgs) throw new Error('No remote auth request started on this page')
const exchangeCode: string = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timed out waiting for remote auth completion')), timeoutMs)
const iv = setInterval(() => {
const done = msgs.find(m => m.status === 'authenticated' && m.exchange_code)
const failed = msgs.find(m => ['denied', 'expired', 'timeout', 'cancelled'].includes(m.status) || (typeof m.status === 'number' && m.status >= 400))
if (done) {
clearTimeout(timer); clearInterval(iv)
resolve(done.exchange_code)
} else if (failed) {
clearTimeout(timer); clearInterval(iv)
reject(new Error(failed.detail || `Remote auth ${failed.status}`))
}
}, 50)
})
const resp = await fetch('/auth/api/set-session', {
method: 'POST',
headers: { 'Authorization': `Bearer ${exchangeCode}` },
})
if (!resp.ok) throw new Error(`set-session failed: ${resp.status}`)
const validate = await fetch('/auth/api/validate', { method: 'POST' })
if (!validate.ok) throw new Error(`validate failed: ${validate.status}`)
return await validate.json()
}, { timeoutMs })
}
/**
* Permit a remote auth request from the given page (the authenticating device).
* The page must be on the approver's origin with a valid session cookie and a
* virtual authenticator holding a credential for that domain.
* Resolves with the "found" message (includes the requesting domain's rp_id).
*/
export async function permitRemoteAuth(page: Page, code: string): Promise<any> {
return page.evaluate(async ({ code, powSrc, b64src }) => {
const solvePoW = eval(`(${powSrc})`)
const { b64dec, b64enc } = eval(`(() => { ${b64src}; return { b64dec, b64enc } })()`)
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://${location.host}/auth/ws/remote-auth/permit`)
let stage = 0
let foundMsg: any = null
ws.onmessage = async (event) => {
const data = JSON.parse(event.data)
try {
if (typeof data.status === 'number' && data.status >= 400) {
ws.close()
reject(new Error(data.detail || `permit failed: ${data.status}`))
return
}
if (data.pow && stage === 0) {
const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work)
stage = 1
ws.send(JSON.stringify({ code, pow: b64enc(solution) }))
return
}
if (data.status === 'found') {
foundMsg = data
const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work)
stage = 2
ws.send(JSON.stringify({ authenticate: true, pow: b64enc(solution) }))
return
}
if (data.optionsJSON) {
const opts = data.optionsJSON
const credential = await navigator.credentials.get({
publicKey: {
challenge: b64dec(opts.challenge),
rpId: opts.rpId,
timeout: opts.timeout,
userVerification: opts.userVerification,
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
type: cred.type,
id: b64dec(cred.id),
transports: cred.transports,
})) || [],
}
}) as PublicKeyCredential | null
if (!credential) throw new Error('Failed to get credential')
const response = credential.response as AuthenticatorAssertionResponse
ws.send(JSON.stringify({
id: credential.id,
rawId: b64enc(credential.rawId),
response: {
clientDataJSON: b64enc(response.clientDataJSON),
authenticatorData: b64enc(response.authenticatorData),
signature: b64enc(response.signature),
userHandle: response.userHandle ? b64enc(response.userHandle) : null,
},
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: (credential as any).authenticatorAttachment,
}))
return
}
if (data.status === 'success') {
ws.close()
resolve(foundMsg)
return
}
} catch (err: any) {
ws.close()
reject(new Error(err.message || 'Permit failed'))
}
}
ws.onerror = () => reject(new Error('WebSocket error during permit'))
})
}, { code, powSrc: solvePoWSource, b64src: b64helpersSource })
}
+81 -83
View File
@@ -1,4 +1,4 @@
import { execSync, spawn } from 'child_process'
import { execFileSync, spawn, spawnSync } from 'child_process'
import { join, dirname } from 'path'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
@@ -20,55 +20,81 @@ interface TestState {
/**
* Global setup for E2E tests.
*
* Uses in-memory SQLite database for fast, isolated tests.
* Captures the bootstrap reset token for initial user registration.
* Bootstraps a fresh combined database (paskia.kantadb) with two domains —
* localhost (default) and test.localhost — then starts the server with the
* test data directory as its working directory. Captures the bootstrap reset
* token from 'paskia init' output for initial user registration.
*/
export default async function globalSetup() {
console.log('\n🔧 Setting up E2E test environment...\n')
// Create test data directory for state file
if (!existsSync(testDataDir)) {
mkdirSync(testDataDir, { recursive: true })
}
// Start from a clean slate: the test data directory doubles as the server
// working directory, so paskia.kantadb and paskia.data/ are created here
rmSync(testDataDir, { recursive: true, force: true })
mkdirSync(testDataDir, { recursive: true })
// Build the package first
console.log(' Building package with uv build...')
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
execFileSync('uv', ['build'], { cwd: projectRoot, stdio: 'inherit' })
console.log(' ✅ Build complete\n')
console.log(' Starting server with in-memory database...')
if (COLLECT_COVERAGE) {
console.log(' 📊 Coverage collection enabled for Python backend')
}
const state: TestState = {}
// Build server command - with or without coverage
const serverArgs = COLLECT_COVERAGE
? [
'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
: [
'run', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
// Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing stale test database...')
rmSync(testDbFile, { force: true, recursive: true })
// Bootstrap the database: two domains, localhost and test.localhost
console.log(' Bootstrapping database with paskia init...')
const initResult = spawnSync(
'uv',
[
'run', '--project', projectRoot,
'paskia', 'init', '-l', 'localhost:4404', 'localhost',
],
{ cwd: testDataDir, encoding: 'utf-8' }
)
const initOutput = `${initResult.stdout}${initResult.stderr}`
process.stdout.write(initOutput)
if (initResult.status !== 0) {
throw new Error(`paskia init failed with exit code ${initResult.status}`)
}
const addResult = spawnSync(
'uv',
['run', '--project', projectRoot, 'paskia', 'init', 'test.localhost'],
{ cwd: testDataDir, encoding: 'utf-8' }
)
process.stdout.write(`${addResult.stdout}${addResult.stderr}`)
if (addResult.status !== 0) {
throw new Error(`paskia init test.localhost failed with exit code ${addResult.status}`)
}
// Start the server using Node's spawn
// Parse the reset token from init output
// Format: http://localhost:4404/auth/{token} where token is dot-separated words
const match = initOutput.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
if (!match) {
throw new Error('Failed to capture reset token from paskia init output')
}
state.resetToken = match[1]
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
// Start the server (serve mode: all configuration comes from the database)
console.log(' Starting server...')
const serverArgs = COLLECT_COVERAGE
? [
'run', '--project', projectRoot,
'coverage', 'run', '--parallel-mode',
'-m', 'paskia', '-l', 'localhost:4404'
]
: [
'run', '--project', projectRoot,
'paskia', '-l', 'localhost:4404'
]
const serverProcess = spawn('uv', serverArgs, {
cwd: projectRoot,
cwd: testDataDir,
env: {
...process.env,
PASKIA_DB: testDbFile,
COVERAGE_FILE: join(projectRoot, '.coverage'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -76,66 +102,38 @@ export default async function globalSetup() {
state.serverPid = serverProcess.pid
// Capture output to find reset token
const resetTokenPromise = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timed out waiting for server bootstrap (30s)'))
}, 30000)
serverProcess.stdout?.on('data', (data: Buffer) => process.stdout.write(data))
serverProcess.stderr?.on('data', (data: Buffer) => process.stderr.write(data))
let output = ''
const handleData = (data: Buffer) => {
const text = data.toString()
output += text
process.stdout.write(text) // Echo to console
// Look for the reset token URL in the output
// Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token}
// where token is word.word.word.word.word (dot separated)
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
if (match) {
clearTimeout(timeout)
// Wait a bit for server to fully start
setTimeout(() => resolve(match[1]), 1000)
}
serverProcess.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Server exited unexpectedly with code ${code}`)
}
serverProcess.stdout?.on('data', handleData)
serverProcess.stderr?.on('data', handleData)
serverProcess.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
serverProcess.on('exit', (code) => {
if (code !== 0 && code !== null) {
clearTimeout(timeout)
reject(new Error(`Server exited with code ${code}`))
}
})
})
try {
state.resetToken = await resetTokenPromise
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
} catch (err) {
console.error('Failed to capture reset token:', err)
serverProcess.kill()
throw err
// Wait for the server to become ready and fetch the session cookie name
console.log(' Waiting for server readiness...')
const deadline = Date.now() + 30000
let settings: any = null
while (Date.now() < deadline) {
try {
const response = await fetch('http://localhost:4404/auth/api/settings')
if (response.ok) {
settings = await response.json()
break
}
} catch {
// Not up yet
}
await new Promise(r => setTimeout(r, 250))
}
// Fetch session cookie name from server settings
try {
const response = await fetch('http://localhost:4404/auth/api/settings')
const settings = await response.json()
state.sessionCookie = settings.session_cookie
console.log(` ✅ Session cookie name: ${state.sessionCookie}\n`)
} catch (err) {
console.error('Failed to fetch settings:', err)
if (!settings) {
serverProcess.kill()
throw err
throw new Error('Server did not become ready in time (30s)')
}
state.sessionCookie = settings.session_cookie
console.log(` ✅ Session cookie name: ${state.sessionCookie}`)
console.log(` ✅ Domain: ${settings.rp_id} (${settings.rp_name})\n`)
// Save state for tests
writeFileSync(stateFile, JSON.stringify(state, null, 2))
+7 -5
View File
@@ -59,11 +59,13 @@ export default async function globalTeardown() {
rmSync(stateFile, { force: true })
}
// Clean up test database
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing test database...')
rmSync(testDbFile, { force: true, recursive: true })
// Clean up test database and auxiliary data
for (const name of ['paskia.kantadb', 'paskia.data']) {
const p = join(testDataDir, name)
if (existsSync(p)) {
console.log(` Removing ${name}...`)
rmSync(p, { force: true, recursive: true })
}
}
// Generate Python coverage report if coverage was collected