Files
technitium-console/hub/server.test.ts
T
DenozordecandClaude Opus 5.5 8bb1e01936 Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:06:37 +07:00

347 lines
14 KiB
TypeScript

// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
/* A fake Technitium: answers `canned(path, query)` and records every request. */
interface Fake {
server: http.Server
url: string
calls: { path: string; query: URLSearchParams }[]
canned: (path: string, query: URLSearchParams) => unknown
down: boolean
}
function fakeUpstream(canned: Fake['canned']): Promise<Fake> {
return new Promise((resolve) => {
const fake: Fake = { server: http.createServer(), url: '', calls: [], canned, down: false }
fake.server.on('request', (req, res) => {
if (fake.down) {
req.socket.destroy()
return
}
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
fake.calls.push({ path: apiPath, query: url.searchParams })
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ status: 'ok', response: fake.canned(apiPath, url.searchParams) }))
})
fake.server.listen(0, '127.0.0.1', () => {
fake.url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`
resolve(fake)
})
})
}
const stats = (queries: number) => ({
stats: { totalQueries: queries, totalBlocked: queries / 10 },
mainChartData: { labels: ['a', 'b'], datasets: [{ label: 'Total', data: [queries, queries] }] },
topDomains: [{ name: 'x.example', hits: queries }],
})
function logPage(prefix: string, stamps: string[], total: number) {
return {
pageNumber: 1,
totalPages: 1,
totalEntries: total,
entries: stamps.map((timestamp, i) => ({ rowNumber: i + 1, timestamp, qname: `${prefix}${i}.example` })),
}
}
function canned(queries: number, stamps: string[]): Fake['canned'] {
return (apiPath, query) => {
switch (apiPath) {
case 'dashboard/stats/get':
return stats(queries)
case 'dashboard/stats/getTop':
return { topDomains: [{ name: 'x.example', hits: queries }, { name: `only-${queries}.example`, hits: 1 }] }
case 'logs/query':
return logPage(`q${queries}-`, stamps.slice(0, Number(query.get('entriesPerPage') ?? 25)), stamps.length)
case 'apps/list':
return { apps: [{ name: 'Shared' }, { name: `App ${queries}` }] }
case 'settings/get':
return { enableBlocking: true, from: queries }
default:
return {}
}
}
}
let a: Fake
let b: Fake
let hub: http.Server
let base: string
let staticDir: string
let dataDir: string
function makeConfig(over: Record<string, string> = {}) {
return parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'token-a',
B_TOKEN: 'token-b',
HUB_USER: 'admin',
HUB_PASSWORD: 'secret',
HUB_STATIC: staticDir,
HUB_DATA: dataDir,
HUB_CACHE_SECONDS: '15',
HUB_TIMEOUT_MS: '2000',
...over,
})
}
async function listen(server: http.Server): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
}
async function call(apiPath: string, params: Record<string, string> = {}, token?: string, method = 'GET') {
const query = new URLSearchParams(params)
const url = `${base}/api/${apiPath}${method === 'GET' && params ? `?${query}` : ''}`
const init: RequestInit = { method, headers: token ? { authorization: `Bearer ${token}` } : {} }
if (method === 'POST') {
init.body = query.toString()
init.headers = { ...init.headers, 'content-type': 'application/x-www-form-urlencoded' }
}
const res = await fetch(url, init)
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
async function login(): Promise<string> {
const { body } = await call('user/login', { user: 'admin', pass: 'secret', includeInfo: 'true' }, undefined, 'POST')
return body.token as string
}
beforeAll(async () => {
a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z']))
b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z']))
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-'))
writeFileSync(path.join(staticDir, 'index.html'), '<html>root</html>')
mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true })
writeFileSync(path.join(staticDir, 'logs', 'index.html'), '<html>logs</html>')
mkdirSync(path.join(staticDir, 'assets'))
writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}')
})
afterAll(() => {
a.server.close()
b.server.close()
rmSync(staticDir, { recursive: true, force: true })
rmSync(dataDir, { recursive: true, force: true })
})
beforeEach(async () => {
a.down = false
b.down = false
hub?.close()
hub = createHub(makeConfig())
base = await listen(hub)
a.calls.length = 0
b.calls.length = 0
})
describe('config', () => {
it('refuses to start with half the credentials or without a token', () => {
const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' }
expect(() => parseConfig({ ...env, HUB_USER: 'u' })).toThrow(/HUB_USER/)
expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/)
})
it('derives the token variable from the id and applies defaults', () => {
const c = parseConfig({ HUB_SERVERS: 'my-dns.1=http://x/', MY_DNS_1_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' })
expect(c.servers).toEqual([{ id: 'my-dns.1', url: 'http://x', token: 't' }])
expect([c.port, c.cacheSeconds, c.timeoutMs, c.domain, c.staticDir]).toEqual([8080, 15, 8000, 'hub', './dist'])
})
})
describe('session', () => {
it('logs in and describes the servers as cluster nodes', async () => {
const token = await login()
expect(token).toMatch(/^[0-9a-f]{64}$/)
const { body } = await call('user/session/get', {}, token)
expect(body.status).toBe('ok')
expect(body.info.hub).toBe(true)
expect(body.info.clusterInitialized).toBe(true)
expect(body.info.clusterNodes).toEqual([{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }])
expect(body.info.permissions.Dashboard.canView).toBe(true)
expect(body.info.permissions.Logs.canView).toBe(true)
expect(body.info.permissions.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
expect(body.info.permissions.Administration).toEqual({ canView: false, canModify: false, canDelete: false })
})
it('refuses a wrong password', async () => {
const { body } = await call('user/login', { user: 'admin', pass: 'nope' }, undefined, 'POST')
expect(body.status).toBe('error')
expect(body.token).toBeUndefined()
})
it('answers invalid-token for a missing, unknown or logged-out token', async () => {
expect((await call('dashboard/stats/get')).body.status).toBe('invalid-token')
expect((await call('dashboard/stats/get', {}, 'bogus')).body.status).toBe('invalid-token')
const token = await login()
await call('user/logout', {}, token)
expect((await call('user/session/get', {}, token)).body.status).toBe('invalid-token')
})
it('accepts the token as a parameter too', async () => {
const token = await login()
expect((await call('user/checkForUpdate', { token })).body.response.updateAvailable).toBe(false)
})
})
describe('read-only', () => {
it('refuses everything that is not a listed read', async () => {
const token = await login()
const { code, body } = await call('admin/users/delete', { user: 'x' }, token, 'POST')
expect(code).toBe(403)
expect(body).toEqual({ status: 'error', errorMessage: 'The hub is read-only.' })
expect(a.calls).toHaveLength(0)
})
})
describe('single node', () => {
it('forwards to that server with its token and without node', async () => {
const token = await login()
const { body } = await call('dashboard/stats/get', { type: 'LastDay', node: 'b' }, token)
expect(body.response.stats.totalQueries).toBe(300)
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(1)
expect(b.calls[0].query.get('token')).toBe('token-b')
expect(b.calls[0].query.has('node')).toBe(false)
expect(b.calls[0].query.get('type')).toBe('LastDay')
expect(body.hubWarnings).toBeUndefined()
})
it('reports an unreachable server by its id', async () => {
const token = await login()
a.down = true
const { body } = await call('settings/get', { node: 'a' }, token)
expect(body.status).toBe('error')
expect(body.errorMessage).toMatch(/^a did not answer: /)
expect(body.errorMessage).not.toContain('token-a')
})
it('rejects an unknown node', async () => {
const token = await login()
expect((await call('settings/get', { node: 'zzz' }, token)).body.status).toBe('error')
})
})
describe('aggregate', () => {
it('sums the stats of every server', async () => {
const token = await login()
for (const node of [undefined, 'cluster']) {
const params: Record<string, string> = { type: 'LastDay' }
if (node) params.node = node
const { body } = await call('dashboard/stats/get', params, token)
expect(body.response.stats.totalQueries).toBe(400)
expect(body.response.mainChartData.datasets[0].data).toEqual([400, 400])
expect(body.hubWarnings).toEqual([])
}
})
it('merges the top lists by name', async () => {
const token = await login()
const { body } = await call('dashboard/stats/getTop', { type: 'LastDay', statsType: 'TopDomains', limit: '2' }, token)
expect(body.response.topDomains).toEqual([
{ name: 'x.example', hits: 400 },
{ name: 'only-100.example', hits: 1 },
])
})
it('merges query logs across servers with node tags and pages', async () => {
const token = await login()
const page1 = await call('logs/query', { pageNumber: '1', entriesPerPage: '3', descendingOrder: 'true' }, token)
expect(page1.body.response.totalEntries).toBe(4)
expect(page1.body.response.totalPages).toBe(2)
expect(page1.body.response.entries.map((e: any) => [e.node, e.timestamp])).toEqual([
['a', '2026-01-01T10:00:04Z'],
['b', '2026-01-01T10:00:03Z'],
['a', '2026-01-01T10:00:02Z'],
])
// Each server was asked for the head of the list that page N needs.
expect(a.calls[0].query.get('entriesPerPage')).toBe('3')
expect(a.calls[0].query.get('pageNumber')).toBe('1')
a.calls.length = 0
const page2 = await call('logs/query', { pageNumber: '2', entriesPerPage: '3', descendingOrder: 'true' }, token)
expect(a.calls[0].query.get('entriesPerPage')).toBe('6')
expect(page2.body.response.entries.map((e: any) => [e.node, e.rowNumber])).toEqual([['b', 4]])
const asc = await call('logs/query', { pageNumber: '1', entriesPerPage: '2', descendingOrder: 'false' }, token)
expect(asc.body.response.entries.map((e: any) => e.timestamp)).toEqual(['2026-01-01T10:00:01Z', '2026-01-01T10:00:02Z'])
})
it('unions the apps by name and asks the first server for other reads', async () => {
const token = await login()
const apps = await call('apps/list', {}, token)
expect(apps.body.response.apps.map((x: any) => x.name)).toEqual(['Shared', 'App 100', 'App 300'])
const settings = await call('settings/get', {}, token)
expect(settings.body.response.from).toBe(100)
expect(b.calls).toHaveLength(1)
})
it('serves a repeated call from the cache', async () => {
const token = await login()
await call('dashboard/stats/get', { type: 'LastDay' }, token)
await call('dashboard/stats/get', { type: 'LastDay' }, token)
expect(a.calls).toHaveLength(1)
await call('dashboard/stats/get', { type: 'LastWeek' }, token)
expect(a.calls).toHaveLength(2)
})
it('answers with the other server and a warning when one is down', async () => {
const token = await login()
b.down = true
const { body } = await call('dashboard/stats/get', { type: 'Warn' }, token)
expect(body.status).toBe('ok')
expect(body.response.stats.totalQueries).toBe(100)
expect(body.hubWarnings).toHaveLength(1)
expect(body.hubWarnings[0]).toMatch(/^b: /)
})
it('is an error when every server is down', async () => {
const token = await login()
a.down = true
b.down = true
const { body } = await call('dashboard/stats/get', { type: 'Dead' }, token)
expect(body.status).toBe('error')
expect(body.errorMessage).toMatch(/No server answered/)
})
})
describe('static files', () => {
it('serves files and the index of a folder, with the console headers on html', async () => {
const root = await fetch(`${base}/`)
expect(await root.text()).toBe('<html>root</html>')
expect(root.headers.get('content-security-policy')).toBe("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'")
expect(root.headers.get('x-content-type-options')).toBe('nosniff')
const logs = await fetch(`${base}/logs/`)
expect(await logs.text()).toBe('<html>logs</html>')
const js = await fetch(`${base}/assets/app.js`)
expect(js.headers.get('content-type')).toMatch(/^text\/javascript/)
})
it('redirects a folder without the slash and 404s the rest', async () => {
const redirect = await fetch(`${base}/logs`, { redirect: 'manual' })
expect(redirect.status).toBe(301)
expect(redirect.headers.get('location')).toBe('/logs/')
expect((await fetch(`${base}/nope.js`)).status).toBe(404)
// A folder with no index of its own is not a route.
expect((await fetch(`${base}/assets`, { redirect: 'manual' })).status).toBe(404)
expect((await fetch(`${base}/..%2f..%2fetc/passwd`)).status).toBeGreaterThanOrEqual(400)
})
it('answers the api under a sub path', async () => {
const res = await fetch(`${base}/dashboard/api/status`)
expect(((await res.json()) as { status: string }).status).toBe('ok')
})
})