Files
wiki-print/test/api.test.js
T
2026-11-13 11:50:48 +01:00

202 lines
6.3 KiB
JavaScript

import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { startMockWiki } from './mock-wiki.js'
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
function freePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close((error) => (error ? reject(error) : resolve(port)))
})
})
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function startApp({ port, wikiUrl, apiKey = 'test-key', extraEnv = {} }) {
const env = {
...process.env,
NODE_ENV: 'test',
HOST: '127.0.0.1',
PORT: String(port),
WIKI_URL: wikiUrl || '',
WIKI_API_KEY: apiKey,
...extraEnv,
}
delete env.MAX_PAGES
delete env.AUTH_USER
delete env.AUTH_PASSWORD
const child = spawn(process.execPath, ['src/server.js'], {
cwd: ROOT,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
child.stdout.on('data', (chunk) => {
output += chunk
})
child.stderr.on('data', (chunk) => {
output += chunk
})
const baseUrl = `http://127.0.0.1:${port}`
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) break
try {
const response = await fetch(`${baseUrl}/healthz`)
if (response.ok) return { baseUrl, child, output: () => output }
} catch {
// The server is not listening yet.
}
await sleep(50)
}
child.kill('SIGKILL')
throw new Error(`server did not start:\n${output}`)
}
async function stopApp(app) {
if (!app) return
if (app.child.exitCode === null) {
await new Promise((resolve) => {
app.child.once('exit', resolve)
app.child.kill('SIGTERM')
setTimeout(() => app.child.kill('SIGKILL'), 5_000).unref()
})
}
}
test('serves the wiki page list and keeps the connection details server-side', async () => {
const wiki = await startMockWiki()
const port = await freePort()
const app = await startApp({ port, wikiUrl: wiki.url })
try {
const configResponse = await fetch(`${app.baseUrl}/api/config`)
assert.equal(configResponse.status, 200)
assert.match(configResponse.headers.get('x-request-id') || '', /^[0-9a-f-]{36}$/)
const config = await configResponse.json()
assert.equal(config.wikiUrlConfigured, true)
assert.equal(config.wikiUrl, wiki.url)
assert.equal(config.apiKeyConfigured, true)
assert.equal('apiKey' in config, false, 'the API key must never be sent to the browser')
const pagesResponse = await fetch(`${app.baseUrl}/api/pages`)
assert.equal(pagesResponse.status, 200)
const pagesPayload = await pagesResponse.json()
assert.equal(pagesPayload.baseUrl, wiki.url)
assert.equal(pagesPayload.defaultLocale, 'en')
assert.equal(pagesPayload.namespacing, true)
assert.equal(pagesPayload.pages.length, wiki.pages.length)
const docs = pagesPayload.pages.find((page) => page.path === 'docs')
assert.deepEqual(docs, {
path: 'docs',
locale: 'en',
title: 'Documentation',
description: 'Documentation root',
isPublished: true,
})
assert.equal(
pagesPayload.pages.find((page) => page.path === 'docs/draft').isPublished,
false,
)
const paths = pagesPayload.pages.map((page) => page.path)
assert.deepEqual(paths, [...paths].sort((a, b) => a.localeCompare(b, 'en')))
} finally {
await stopApp(app)
await wiki.close()
}
})
test('rejects an export without a starting page and does not require a browser URL', async () => {
const wiki = await startMockWiki()
const port = await freePort()
const app = await startApp({ port, wikiUrl: wiki.url })
try {
const response = await fetch(`${app.baseUrl}/api/exports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startPage: '' }),
})
assert.equal(response.status, 400)
const payload = await response.json()
assert.equal(payload.error, 'The starting page is required.')
// A browser-supplied baseUrl/API key must be ignored: the server uses
// WIKI_URL and WIKI_API_KEY only. A missing page fails before Chromium
// starts, so this test does not need a browser.
const accepted = await fetch(`${app.baseUrl}/api/exports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
baseUrl: 'https://attacker.example',
apiKey: 'attacker-key',
startPage: 'missing-page',
maxPages: 1,
}),
})
assert.equal(accepted.status, 202)
const { job } = await accepted.json()
assert.equal(job.request.baseUrl, wiki.url)
assert.equal('apiKey' in job.request, false)
assert.equal('apiKey' in job, false)
let status = job
for (let attempt = 0; attempt < 100 && status.status !== 'error'; attempt += 1) {
await sleep(50)
status = (await (await fetch(`${app.baseUrl}/api/exports/${job.id}`)).json()).job
}
assert.equal(status.status, 'error')
assert.equal(status.error.code, 'START_PAGE_NOT_FOUND')
} finally {
await stopApp(app)
await wiki.close()
}
})
test('reports missing and invalid WIKI_URL without crashing', async () => {
const port = await freePort()
const app = await startApp({ port, wikiUrl: '' })
try {
const config = await (await fetch(`${app.baseUrl}/api/config`)).json()
assert.equal(config.wikiUrlConfigured, false)
assert.equal(config.wikiUrl, null)
const pagesResponse = await fetch(`${app.baseUrl}/api/pages`)
assert.equal(pagesResponse.status, 503)
const pagesPayload = await pagesResponse.json()
assert.equal(pagesPayload.code, 'WIKI_URL_MISSING')
} finally {
await stopApp(app)
}
const invalidPort = await freePort()
const invalidApp = await startApp({ port: invalidPort, wikiUrl: 'ftp://not-a-wiki' })
try {
const pagesResponse = await fetch(`${invalidApp.baseUrl}/api/pages`)
assert.equal(pagesResponse.status, 503)
const payload = await pagesResponse.json()
assert.equal(payload.code, 'WIKI_URL_MISSING')
assert.match(payload.error, /WIKI_URL is invalid/)
} finally {
await stopApp(invalidApp)
}
})