Files
eleith a15105804c
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
[web] keep app configuration out of Vite discovery
2026-11-14 08:19:33 -08:00

280 lines
9.7 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from 'vitest'
import { execFile } from 'node:child_process'
import {
mkdir,
mkdtemp,
readFile,
readlink,
rename,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
const exec = promisify(execFile)
const manager = fileURLToPath(new URL('./manager.js', import.meta.url))
const launcher = fileURLToPath(new URL('./start-server.js', import.meta.url))
const absoluteEnvironment = {
HOST: '127.0.0.1',
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
HOST_HEADER: 'x-luzzle-public-authority',
PORT_HEADER: ''
}
const cache = fileURLToPath(new URL('../node_modules/.cache/', import.meta.url))
let directory: string
beforeEach(async () => {
await mkdir(cache, { recursive: true })
directory = await mkdtemp(path.join(cache, 'manager-startup-'))
await Promise.all(['bin', 'build', 'data'].map((name) => mkdir(path.join(directory, name))))
// Stub only the package-manager subprocess. The real manager and config loader run in Node.
await writeFile(
path.join(directory, 'bin/pnpm'),
'#!/bin/sh\nprintf "manager-command %s\\n" "$*"\n',
{ mode: 0o755 }
)
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
function run(dev = true, environment: NodeJS.ProcessEnv = {}) {
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, [manager], {
cwd: directory,
env: {
...inherited,
LUZZLE_DEV: String(dev),
PATH: `${path.join(directory, 'bin')}${path.delimiter}${process.env.PATH}`,
...environment
},
timeout: 10000
})
}
async function configure(
app = 'https://manager.example/garden',
authenticated = false,
hmrPort?: number
) {
const config: Record<string, unknown> = {
url: { app },
network: { public: { host: '127.0.0.1', hmr_port: hmrPort } }
}
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('development startup loads config from the mounted working directory without an /app alias', async () => {
await configure()
const { stdout } = await run()
expect(stdout).toContain('Config App URL: https://manager.example/garden')
expect(stdout).toContain('Running in development mode...')
expect(stdout).toContain('manager-command run dev -- --host')
expect(stdout).not.toContain('Initiating production build sequence')
})
test('development passes only YAML-derived networking settings to Vite', async () => {
await configure('https://manager.example:8443/garden', true, 8443)
await writeFile(
path.join(directory, 'bin/pnpm'),
'#!/bin/sh\nprintf "vite-settings %s\\n" "$LUZZLE_DEV_SERVER"\n',
{ mode: 0o755 }
)
const { stdout } = await run(true, { LUZZLE_DEV_SERVER: '{"allowedHosts":true}' })
const settings = JSON.parse(stdout.match(/^vite-settings (.+)$/m)![1])
expect(settings).toEqual({
appOrigin: 'https://manager.example:8443',
host: '127.0.0.1',
allowedHosts: ['localhost', 'manager.example', 'luzzle-web'],
hmr: { clientPort: 8443 }
})
})
test('production delegates to the absolute real launcher with build and log files in the working directory', async () => {
await configure()
await writeFile(
path.join(directory, 'build/index.js'),
`import { createServer } from 'node:http'
export const server = createServer()
console.log("manager-server " + JSON.stringify({
cwd:process.cwd(),host:process.env.HOST,launcher:process.argv[1]
}))`
)
const { stdout } = await run(false)
expect(stdout).toContain('manager-command run build')
expect(stdout).toContain(
`manager-server ${JSON.stringify({ cwd: directory, host: '127.0.0.1', launcher })}`
)
const log = await readFile(path.join(directory, 'data/build.log'), 'utf8')
expect(log).toContain('Config App URL: https://manager.example/garden')
expect(log).toContain('Build completed successfully.')
})
test('fresh production startups follow the compatibility link after atomic config replacement', async () => {
const filename = path.join(directory, 'config.yaml')
const target = path.join(directory, 'config/config.yaml')
await configure()
await mkdir(path.join(directory, 'config'))
await rename(filename, target)
await symlink('config/config.yaml', filename)
await writeFile(
path.join(directory, 'build/index.js'),
`import { createServer } from 'node:http'
export const server = createServer()
console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))`
)
// Each run is a fresh manager process, not a live-server config reload.
async function checkStartup(app: string, host: string) {
const { stdout } = await run(false)
expect(await readlink(filename)).toBe('config/config.yaml')
expect(stdout).toContain(`Config App URL: ${app}`)
expect(stdout).toContain('manager-command run build')
expect(stdout).toContain(`manager-server ${JSON.stringify({ cwd: directory, host })}`)
const log = await readFile(path.join(directory, 'data/build.log'), 'utf8')
expect(log).toContain(`Config App URL: ${app}`)
expect(log).toContain('Build completed successfully.')
}
await checkStartup('https://manager.example/garden', '127.0.0.1')
const app = 'https://replacement.example/garden'
const host = '0.0.0.0'
const temporary = path.join(directory, 'config/config.next.yaml')
await writeFile(temporary, JSON.stringify({ url: { app }, network: { public: { host } } }))
await rename(temporary, target)
await checkStartup(app, host)
}, 25000)
test('startup still fails when the working-directory config is missing', async () => {
await expect(run()).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining(path.join(directory, 'config.yaml'))
})
})
async function captureServerEnvironment() {
await writeFile(
path.join(directory, 'build/index.js'),
`import { createServer } from 'node:http'
// Keep the native export unbound so this environment snapshot exits naturally.
export const server = createServer()
console.log("manager-environment " + 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
}))`
)
}
test('production selects internal protocol and authority headers from configured url.app', async () => {
await configure('https://Manager.EXAMPLE:443/garden/', true)
await captureServerEnvironment()
const { stdout } = await run(false)
expect(stdout).toContain('manager-command run build')
expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`)
})
test('production uses YAML even when ambient URL variables disagree', async () => {
await configure(undefined, true)
await captureServerEnvironment()
const { stdout } = await run(false, {
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: '',
NEXTAUTH_URL: ''
})
expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`)
})
test.each(['', '/garden'])(
'production keeps app URL %s request-derived and clears ambient ORIGIN',
async (app) => {
await configure(app, false)
await captureServerEnvironment()
const { stdout } = await run(false, {
ORIGIN: 'https://unexpected.example',
PROTOCOL_HEADER: 'x-forwarded-proto'
})
expect(stdout).toContain(
`manager-environment ${JSON.stringify({
HOST: '127.0.0.1',
PROTOCOL_HEADER: 'x-forwarded-proto'
})}`
)
}
)
test('production selects configured header settings even with authentication disabled', async () => {
await configure()
await captureServerEnvironment()
const { stdout } = await run(false)
expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`)
})
test('development selects the same internal headers from YAML and clears ambient ORIGIN', async () => {
await configure(undefined, true)
await writeFile(
path.join(directory, 'bin/pnpm'),
'#!/bin/sh\nprintf "manager-environment HOST=%s ORIGIN=%s PROTOCOL_HEADER=%s HOST_HEADER=%s PORT_HEADER=%s\\n" "$HOST" "${ORIGIN-unset}" "$PROTOCOL_HEADER" "$HOST_HEADER" "${PORT_HEADER-unset}"\n',
{ mode: 0o755 }
)
const { stdout } = await run(true, { ORIGIN: 'https://unexpected.example' })
expect(stdout).toContain(
'manager-environment HOST=127.0.0.1 ORIGIN=unset PROTOCOL_HEADER=x-luzzle-public-protocol HOST_HEADER=x-luzzle-public-authority PORT_HEADER=\n'
)
expect(stdout).not.toContain('Initiating production build sequence')
})
test('production rejects unsafe configured URLs before logging values, building or spawning Node', async () => {
await configure('https://user:private-password@manager.example/garden', true)
await captureServerEnvironment()
await expect(run(false)).rejects.toMatchObject({
code: 1,
stdout: 'FATAL ERROR: Invalid url.app: expected an HTTP(S) URL without credentials\n'
})
const log = await readFile(path.join(directory, 'data/build.log'), 'utf8')
expect(log).toContain('FATAL ERROR: Invalid url.app:')
expect(log).not.toContain('private-password')
expect(log).not.toContain('private-session-secret')
})
test('authenticated production rejects relative app URLs instead of using an ambient origin', async () => {
await configure('/garden', true)
await expect(
run(false, {
ORIGIN: 'https://unexpected.example',
BETTER_AUTH_URL: 'https://unexpected.example',
AUTH_URL: 'https://legacy.example'
})
).rejects.toMatchObject({
code: 1,
stdout: 'FATAL ERROR: Authentication requires an absolute url.app\n'
})
})