Zones gets "All servers" in hub mode: one table of every zone on every server with its type and serial, marking zones that are on one server only or whose serials or types differ. A zone can be copied to another server (export, create, import; Overwrite must be ticked for a zone that is already there) or made a Secondary there (the source's zone transfer and notify lists gain the target, then the target zone is created). Settings and Blocking can be compared across servers against a baseline kept in the hub. Host fields stay local, secrets are compared inside the hub only and never shown or logged, and an apply sends only the changed shared fields after saving each server's settings to history. Blocked and Allowed lists are compared as sets; extras are removed only when asked. Co-Authored-By: Claude Opus 5.5 <[email protected]>
153 lines
5.6 KiB
TypeScript
153 lines
5.6 KiB
TypeScript
/*
|
|
Test helpers for the stage 7 suites: fake Technitium servers that answer by path and record every
|
|
call, and a hub started against two of them with one user per role. Not used by the hub itself.
|
|
*/
|
|
import http from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { parseConfig } from './config.ts'
|
|
import { createHub } from './server.ts'
|
|
import { addUser, writeUsersFile } from './users.ts'
|
|
|
|
export interface Call {
|
|
path: string
|
|
method: string
|
|
token: string
|
|
query: URLSearchParams
|
|
form: URLSearchParams
|
|
/* A text/plain body (a zone file). */
|
|
text: string
|
|
}
|
|
|
|
/* What a handler answers: the envelope's `response`, an error message, or a file. */
|
|
export type Reply = { response?: unknown } | { error: string } | { text: string }
|
|
|
|
export interface Fake {
|
|
server: http.Server
|
|
url: string
|
|
calls: Call[]
|
|
down: boolean
|
|
handlers: Map<string, (call: Call) => Reply>
|
|
/** Answers one path; later calls replace earlier ones. */
|
|
on: (apiPath: string, handler: (call: Call) => Reply) => void
|
|
}
|
|
|
|
export function fake(): Promise<Fake> {
|
|
return new Promise((resolve) => {
|
|
const f: Fake = {
|
|
server: http.createServer(),
|
|
url: '',
|
|
calls: [],
|
|
down: false,
|
|
handlers: new Map(),
|
|
on(apiPath, handler) {
|
|
f.handlers.set(apiPath, handler)
|
|
},
|
|
}
|
|
f.server.on('request', (req, res) => {
|
|
const chunks: Buffer[] = []
|
|
req.on('data', (c: Buffer) => chunks.push(c))
|
|
req.on('end', () => {
|
|
if (f.down) return req.socket.destroy()
|
|
const url = new URL(req.url ?? '/', 'http://fake')
|
|
const type = String(req.headers['content-type'] ?? '')
|
|
const raw = Buffer.concat(chunks).toString('utf8')
|
|
const call: Call = {
|
|
path: url.pathname.replace(/^\/api\//, ''),
|
|
method: req.method ?? '',
|
|
token: url.searchParams.get('token') ?? '',
|
|
query: url.searchParams,
|
|
form: new URLSearchParams(type.includes('x-www-form-urlencoded') ? raw : ''),
|
|
text: type.includes('text/plain') ? raw : '',
|
|
}
|
|
f.calls.push(call)
|
|
const reply: Reply = f.handlers.get(call.path)?.(call) ?? { response: {} }
|
|
if ('text' in reply) {
|
|
res.setHeader('content-type', 'text/plain')
|
|
return res.end(reply.text)
|
|
}
|
|
res.setHeader('content-type', 'application/json')
|
|
res.end(JSON.stringify('error' in reply ? { status: 'error', errorMessage: reply.error } : { status: 'ok', response: reply.response ?? {} }))
|
|
})
|
|
})
|
|
f.server.listen(0, '127.0.0.1', () => {
|
|
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
|
resolve(f)
|
|
})
|
|
})
|
|
}
|
|
|
|
export const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] as const
|
|
|
|
export interface TestHub {
|
|
base: string
|
|
tokens: Record<string, string>
|
|
dataDir: string
|
|
/** Calls the hub with a role's session. */
|
|
call: (role: string, apiPath: string, params?: Record<string, string>, method?: 'GET' | 'POST') => Promise<{ code: number; body: Record<string, any> }>
|
|
audit: () => Promise<Record<string, string>[]>
|
|
close: () => void
|
|
}
|
|
|
|
export async function startHub(a: Fake, b: Fake, extra: Record<string, string> = {}): Promise<TestHub> {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-data-'))
|
|
const staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-static-'))
|
|
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
|
users = addUser(users, 'operator', 'operator', 'password-operator')
|
|
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
|
users = addUser(users, 'admin', 'admin', 'password-admin')
|
|
writeUsersFile(dataDir, users)
|
|
const hub = createHub(
|
|
parseConfig({
|
|
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
|
A_TOKEN: 'read-a',
|
|
B_TOKEN: 'read-b',
|
|
A_ADMIN_TOKEN: 'admin-a',
|
|
B_ADMIN_TOKEN: 'admin-b',
|
|
HUB_DATA: dataDir,
|
|
HUB_STATIC: staticDir,
|
|
HUB_CACHE_SECONDS: '0',
|
|
HUB_TIMEOUT_MS: '2000',
|
|
HUB_DRIFT_MINUTES: '0',
|
|
...extra,
|
|
}),
|
|
)
|
|
await new Promise<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
|
|
const base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
|
const tokens: Record<string, string> = {}
|
|
for (const role of ROLES) {
|
|
const res = await fetch(`${base}/api/user/login`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: `user=${role}&pass=password-${role}`,
|
|
})
|
|
tokens[role] = ((await res.json()) as { token: string }).token
|
|
}
|
|
const call: TestHub['call'] = async (role, apiPath, params = {}, method = 'POST') => {
|
|
const query = new URLSearchParams(method === 'GET' ? params : {})
|
|
const res = await fetch(`${base}/api/${apiPath}${query.size > 0 ? `?${query}` : ''}`, {
|
|
method,
|
|
headers: { authorization: `Bearer ${tokens[role]}`, ...(method === 'POST' ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) },
|
|
...(method === 'POST' ? { body: new URLSearchParams(params).toString() } : {}),
|
|
})
|
|
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
|
}
|
|
return {
|
|
base,
|
|
tokens,
|
|
dataDir,
|
|
call,
|
|
audit: async () => {
|
|
const res = await fetch(`${base}/api/hub/audit/list?limit=500`, { headers: { authorization: `Bearer ${tokens.admin}` } })
|
|
return ((await res.json()) as { response: { entries: Record<string, string>[] } }).response.entries
|
|
},
|
|
close: () => {
|
|
hub.close()
|
|
rmSync(dataDir, { recursive: true, force: true })
|
|
rmSync(staticDir, { recursive: true, force: true })
|
|
},
|
|
}
|
|
}
|