Files
luzzle/apps/web/scripts/start-server.test.ts
eleith 18761d597d
ci/woodpecker/pr/cli Pipeline was successful
ci/woodpecker/pr/core Pipeline was successful
ci/woodpecker/pr/lsp Pipeline was successful
ci/woodpecker/pr/web-config Pipeline was successful
ci/woodpecker/pr/web-db Pipeline was successful
ci/woodpecker/pr/web-jobs Pipeline was successful
ci/woodpecker/pr/web-lsp Pipeline was successful
ci/woodpecker/pr/web-pieces Pipeline was successful
ci/woodpecker/pr/web-theme Pipeline was successful
ci/woodpecker/pr/web-worker Pipeline was successful
ci/woodpecker/pr/web Pipeline was successful
[web] integrate SvelteKit 3 with runtime configuration
2026-10-31 06:39:44 -07:00

284 lines
9.5 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from 'vitest'
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
const exec = promisify(execFile)
const entry = fileURLToPath(new URL('./start-server.js', import.meta.url))
const cache = fileURLToPath(new URL('../node_modules/.cache/', import.meta.url))
const absoluteEnvironment = {
HOST: '127.0.0.1',
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
HOST_HEADER: 'x-luzzle-public-authority',
PORT_HEADER: ''
}
let directory: string
beforeEach(async () => {
await mkdir(cache, { recursive: true })
// Spaces and # also exercise conversion of the compiled entry path to a file URL.
directory = await mkdtemp(path.join(cache, 'server-startup #-'))
await mkdir(path.join(directory, 'build'))
// Snapshot at module evaluation: the entry must apply the environment before import.
// Export the native Node server, unbound so this snapshot fixture exits naturally.
// Never print the entire environment or any auth secrets in the child fixture.
await writeFile(
path.join(directory, 'build/index.js'),
`import { createServer } from 'node:http'
export const server = createServer()
console.log(JSON.stringify({
HOST:process.env.HOST,ORIGIN:process.env.ORIGIN,
PROTOCOL_HEADER:process.env.PROTOCOL_HEADER,HOST_HEADER:process.env.HOST_HEADER,
PORT_HEADER:process.env.PORT_HEADER
}))`
)
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
function run(environment: NodeJS.ProcessEnv = {}, args = [entry]) {
const inherited = { ...process.env }
for (const key of [
'HOST',
'ORIGIN',
'PROTOCOL_HEADER',
'HOST_HEADER',
'PORT_HEADER',
'BETTER_AUTH_URL',
'AUTH_URL',
'NEXTAUTH_URL'
])
delete inherited[key]
return exec(process.execPath, args, {
cwd: directory,
env: { ...inherited, ...environment },
timeout: 10000
})
}
async function configure(
app = 'https://Startup.EXAMPLE:443/garden/',
authenticated = true,
host = '127.0.0.1'
) {
const config: Record<string, unknown> = {
url: { app },
network: { public: { host } }
}
if (authenticated) {
config.auth = {
secret: 'private-session-secret',
credentials: { username: 'admin', password: 'private-password' }
}
}
await writeFile(path.join(directory, 'config.yaml'), JSON.stringify(config))
}
test('loads working-directory config and applies internal header settings before importing the server', async () => {
await configure()
const { stdout } = await run({ HOST: 'ignored-host' })
expect(JSON.parse(stdout)).toEqual(absoluteEnvironment)
})
test('configured url.app selects internal headers and clears ORIGIN despite ambient URL settings', async () => {
await configure()
const { stdout } = await run({
ORIGIN: 'not-a-url',
PROTOCOL_HEADER: 'x-forwarded-proto',
HOST_HEADER: 'x-forwarded-host',
PORT_HEADER: 'x-forwarded-port',
BETTER_AUTH_URL: 'https://unexpected.example',
AUTH_URL: 'https://legacy.example',
NEXTAUTH_URL: ''
})
expect(JSON.parse(stdout)).toEqual(absoluteEnvironment)
})
test.each(['', '/garden', './garden', '//relative.example/garden'])(
'leaves app URL %s request-derived before importing the server',
async (app) => {
await configure(app, false)
const { stdout } = await run({
ORIGIN: 'https://unexpected.example',
PROTOCOL_HEADER: 'x-forwarded-proto'
})
expect(JSON.parse(stdout)).toEqual({
HOST: '127.0.0.1',
PROTOCOL_HEADER: 'x-forwarded-proto'
})
}
)
test('uses configured header settings even when authentication is disabled', async () => {
await configure(undefined, false)
const { stdout } = await run()
expect(JSON.parse(stdout)).toEqual(absoluteEnvironment)
})
test('uses the existing HOST fallback for an empty configured bind host', async () => {
await configure(undefined, true, '')
const { stdout } = await run()
expect(JSON.parse(stdout)).toEqual({ ...absoluteEnvironment, HOST: '0.0.0.0' })
})
test('import alone exports startServer without loading config or starting the default entry', async () => {
// No config exists; the default entry would print a different snapshot if imported.
const { stdout, stderr } = await run({}, [
'--input-type=module',
'--eval',
`const { startServer } = await import(${JSON.stringify(pathToFileURL(entry).href)})
console.log(typeof startServer)`
])
expect(stdout).toBe('function\n')
expect(stderr).toBe('')
})
test.each(['https://Public.EXAMPLE:8443/garden', 'http://Other.EXAMPLE:8080/base'])(
'startServer returns the native server and normalizes its first HTTP request using %s',
async (app) => {
await configure(app)
const compiledEntry = path.join(directory, 'build/probe #.js')
// This is a native HTTP transport probe, not proof of Kit pre-handle CSRF.
// Like the adapter entry, it installs its request listener and starts listening on import.
await writeFile(
compiledEntry,
`import { createServer } from 'node:http'
export const server = createServer(async (request, response) => {
const protocol = request.headers[process.env.PROTOCOL_HEADER]
const authority = request.headers[process.env.HOST_HEADER]
const url = new URL(request.url, protocol + '://' + authority).href
const headers = { ...request.headers }
let body = ''
for await (const chunk of request) body += chunk
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({ url, method: request.method, headers, body }))
})
server.listen(0, '127.0.0.1')`
)
const { stdout } = await run({ ORIGIN: 'https://ambient.example' }, [
'--input-type=module',
'--eval',
`import { request } from 'node:http'
import { once } from 'node:events'
const { startServer } = await import(${JSON.stringify(pathToFileURL(entry).href)})
const server = await startServer(${JSON.stringify(compiledEntry)})
const compiled = await import(${JSON.stringify(pathToFileURL(compiledEntry).href)})
if (server !== compiled.server) throw new Error('Native server was replaced')
try {
if (!server.listening) await once(server, 'listening')
const results = []
for (const origin of ['https://browser.example', 'null']) {
results.push(await new Promise((resolve, reject) => {
const outgoing = request({
hostname: '127.0.0.1', port: server.address().port, agent: false,
method: 'POST', path: '/submit?next=%2Fprivate&tag=a&tag=b',
headers: {
host: 'internal.example:3000', origin, cookie: 'session=unchanged',
'content-type': 'application/x-www-form-urlencoded',
'x-forwarded-proto': 'http', 'x-forwarded-host': 'spoofed.example:9999',
'x-forwarded-port': '9999', 'x-luzzle-public-protocol': 'http',
'x-luzzle-public-authority': 'spoofed.example:9999'
}
}, async (response) => {
try {
let body = ''
for await (const chunk of response) body += chunk
resolve(JSON.parse(body))
} catch (error) { reject(error) }
})
outgoing.on('error', reject)
outgoing.end('title=one%26two&tag=a&tag=b')
}))
}
console.log(JSON.stringify(results))
} finally {
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}`
])
const canonical = new URL(app)
const results = JSON.parse(stdout)
expect(results).toHaveLength(2)
for (const [index, origin] of ['https://browser.example', 'null'].entries()) {
expect(results[index]).toEqual({
url: `${canonical.origin}/submit?next=%2Fprivate&tag=a&tag=b`,
method: 'POST',
headers: expect.objectContaining({
host: 'internal.example:3000',
origin,
cookie: 'session=unchanged',
'content-type': 'application/x-www-form-urlencoded',
'x-forwarded-proto': 'http',
'x-forwarded-host': 'spoofed.example:9999',
'x-forwarded-port': '9999',
'x-luzzle-public-protocol': canonical.protocol.slice(0, -1),
'x-luzzle-public-authority': canonical.host
}),
body: 'title=one%26two&tag=a&tag=b'
})
}
},
15000
)
test('authentication requires an absolute configured app URL before server import', async () => {
await configure('/garden')
await expect(
run({
ORIGIN: 'https://unexpected.example',
BETTER_AUTH_URL: 'https://unexpected.example',
AUTH_URL: 'https://legacy.example'
})
).rejects.toMatchObject({
code: 1,
stdout: '',
stderr: expect.stringContaining('Authentication requires an absolute url.app')
})
})
test.each([
'https://',
'https:startup.example',
'ftp://startup.example/private-token',
'https://user:private-password@startup.example/garden'
])('rejects unsafe absolute app URL %s before importing the server', async (app) => {
await configure(app)
await expect(run()).rejects.toMatchObject({
code: 1,
stdout: '',
stderr: expect.stringContaining('Invalid url.app: expected an HTTP(S) URL without credentials')
})
})
test('validation errors do not log URL values or auth secrets', async () => {
await configure('https://user:private-password@startup.example/private-token')
try {
await run()
expect.fail('Expected startup validation to fail')
} catch (error) {
const failure = error as Error & { code: number; stdout: string; stderr: string }
expect(failure.code).toBe(1)
expect(failure.stdout).toBe('')
expect(failure.stderr).toContain('Invalid url.app:')
for (const value of [
'private-password',
'private-token',
'private-session-secret',
'startup.example'
]) {
expect(failure.stderr).not.toContain(value)
}
}
})
test('missing config exits naturally before importing the server', async () => {
await expect(run()).rejects.toMatchObject({
code: 1,
stdout: '',
stderr: expect.stringContaining(path.join(directory, 'config.yaml'))
})
})