Files
technitium-console/hub/server-write.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

597 lines
28 KiB
TypeScript

// @vitest-environment node
/*
Stage 5: managing one chosen server from the hub (zones, cache, blocking, DNS client, settings,
DHCP, logs). The servers are fakes that record how they were called.
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdtempSync, readFileSync, rmSync } 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'
import { addUser, writeUsersFile } from './users.ts'
import { webServiceChanges, redactSettings, safeTarget } from './guards.ts'
import { effectiveRoute, routeFor } from './routes.ts'
interface Call {
path: string
method: string
token: string
/* The URL's query alone. */
query: URLSearchParams
/* The form body, when it is one. */
form: URLSearchParams
contentType: string
body: Buffer
}
interface Fake {
server: http.Server
url: string
calls: Call[]
settings: Record<string, unknown>
down: boolean
failWith: string | null
}
const SETTINGS = {
dnsServerDomain: 'a.example',
webServiceLocalAddresses: ['0.0.0.0', '[::]'],
webServiceHttpPort: 5380,
webServiceEnableTls: false,
webServiceTlsCertificatePath: null,
webServiceTlsCertificatePassword: '************',
tsigKeys: [{ keyName: 'k', sharedSecret: 'TOPSECRET', algorithmName: 'hmac-sha256' }],
proxy: { type: 'Http', address: 'p', port: 3128, username: 'u', password: 'PROXYPASS', bypass: [] },
dnsTlsCertificatePassword: 'CERTPASS',
enableBlocking: true,
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', calls: [], settings: { ...SETTINGS }, down: false, failWith: null }
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 apiPath = url.pathname.replace(/^\/api\//, '')
const body = Buffer.concat(chunks)
const contentType = String(req.headers['content-type'] ?? '')
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? body.toString('utf8') : '')
f.calls.push({ path: apiPath, method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form, contentType, body })
if (f.failWith !== null && req.method === 'POST') {
res.setHeader('content-type', 'application/json')
return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
}
if (apiPath === 'zones/export' || apiPath === 'logs/download' || apiPath === 'blocked/export') {
if (url.searchParams.get('zone') === 'missing.example') {
res.setHeader('content-type', 'application/json')
return res.end(JSON.stringify({ status: 'error', errorMessage: 'No such zone: missing.example' }))
}
res.writeHead(200, { 'content-type': 'text/plain', 'content-disposition': `attachment; filename="${apiPath.replace('/', '-')}.txt"` })
return res.end(`line one\nline two (${apiPath})\n`)
}
res.setHeader('content-type', 'application/json')
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
if (apiPath === 'settings/get') return ok(f.settings)
if (apiPath === 'zones/list') return ok({ zones: [{ name: 'example.com' }], pageNumber: 1, totalPages: 1 })
ok({ done: apiPath })
})
})
f.server.listen(0, '127.0.0.1', () => {
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
resolve(f)
})
})
}
let a: Fake
let b: Fake
let dataDir: string
let staticDir: string
let hub: http.Server | undefined
let base: string
const tokens: Record<string, string> = {}
const ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
async function start(extra: Record<string, string> = {}): Promise<void> {
hub?.close()
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',
...extra,
}),
)
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
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
}
}
interface Init {
params?: Record<string, string>
method?: string
body?: string | FormData
headers?: Record<string, string>
auth?: boolean
}
async function api(role: string, apiPath: string, init: Init = {}) {
const query = new URLSearchParams(init.params ?? {})
const headers: Record<string, string> = { ...(init.auth === false ? {} : { authorization: `Bearer ${tokens[role]}` }), ...(init.headers ?? {}) }
const res = await fetch(`${base}/api/${apiPath}?${query}`, { method: init.method ?? (init.body === undefined ? 'GET' : 'POST'), headers, body: init.body })
const text = await res.text()
let json: Record<string, any> = {}
try {
json = JSON.parse(text) as Record<string, any>
} catch {
// A file.
}
return { code: res.status, body: json, text, headers: res.headers }
}
const posts = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
const form = (params: Record<string, string>) => ({
body: new URLSearchParams(params).toString(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
})
async function auditEntries(): Promise<Record<string, string>[]> {
return (await api('admin', 'hub/audit/list', { params: { limit: '200' } })).body.response.entries
}
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-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)
await start()
})
beforeEach(() => {
for (const f of [a, b]) {
f.calls = []
f.down = false
f.failWith = null
f.settings = { ...SETTINGS }
}
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
/* One write of each area, with the lowest capability that may make it. */
const OPERATE = [
['cache/flush', {}],
['cache/delete', { domain: 'x.example' }],
['blocked/add', { domain: 'ads.example' }],
['allowed/delete', { domain: 'ok.example' }],
['settings/temporaryDisableBlocking', { minutes: '5' }],
['settings/forceUpdateBlockLists', {}],
] as const
const ADMIN = [
['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }],
['zones/delete', { zone: 'example.com' }],
['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }],
['dhcp/scopes/enable', { name: 'Default' }],
['settings/set', { enableBlocking: 'true' }],
] as const
describe('who may write what', () => {
it('refuses a viewer on every write, without calling a server', async () => {
for (const [p, params] of [...OPERATE, ...ADMIN]) {
const r = await api('viewer', p, { params: { node: 'a', ...params } })
expect(r.code, p).toBe(403)
expect(r.body.errorMessage).toBe('Your role does not allow this.')
}
expect(posts(a)).toHaveLength(0)
})
it('lets an operator operate (cache, blocking) but not change zones, settings or DHCP', async () => {
for (const [p, params] of OPERATE) expect((await api('operator', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
for (const [p, params] of ADMIN) expect((await api('operator', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
expect(posts(a).map((c) => c.path)).toEqual(OPERATE.map(([p]) => p))
})
it('lets an apps-admin do neither', async () => {
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('apps-admin', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
expect(posts(a)).toHaveLength(0)
})
it('lets an admin do all of it', async () => {
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('admin', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
expect(posts(a)).toHaveLength(OPERATE.length + ADMIN.length)
})
it('tells the console what each role may modify', async () => {
const info = async (role: string) => (await api(role, 'user/session/get')).body.info.permissions
const viewer = await info('viewer')
const operator = await info('operator')
const admin = await info('admin')
expect(viewer.Zones).toEqual({ canView: true, canModify: false, canDelete: false })
expect(operator.Zones.canModify).toBe(false)
expect(operator.Cache).toEqual({ canView: true, canModify: true, canDelete: true })
expect(operator.Blocked.canModify).toBe(true)
expect(operator.Settings.canModify).toBe(false)
expect(admin.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
expect(admin.DhcpServer).toEqual({ canView: true, canModify: true, canDelete: true })
// Backup and restore, deleting logs and stats: nobody.
for (const p of [viewer, operator, admin]) {
expect(p.Logs).toEqual({ canView: true, canModify: false, canDelete: false })
expect(p.Dashboard.canDelete).toBe(false)
expect(p.Settings.canDelete).toBe(false)
expect(p.Administration.canView).toBe(false)
}
})
})
describe('what stays refused', () => {
const REFUSED = [
'admin/users/list', 'admin/users/create', 'admin/sso/set', 'admin/ldap/set', 'admin/groups/list', 'admin/sessions/createToken',
'user/createToken', 'user/changePassword', 'user/profile/get',
'logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll',
'settings/restore', 'settings/backup', 'zones/permissions/get', 'zones/permissions/set',
]
it('answers "read-only" to each, even to an admin, and calls no server', async () => {
for (const p of REFUSED) {
const r = await api('admin', p, { params: { node: 'a' }, method: 'POST', body: '' })
expect(r.code, p).toBe(403)
expect(r.body.errorMessage, p).toBe('The hub is read-only.')
}
expect(a.calls).toHaveLength(0)
const refused = (await auditEntries()).filter((e) => e.action === 'refused-write')
expect(refused.map((e) => e.target)).toEqual(expect.arrayContaining(['logs/delete', 'settings/backup']))
})
})
describe('choosing the server', () => {
it('refuses a write with no node, the aggregate or an unknown node (400) and calls no server', async () => {
for (const node of [undefined, '', 'cluster', 'zzz']) {
const r = await api('admin', 'cache/flush', { params: node === undefined ? {} : { node } })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toBe(node === 'zzz' ? 'Unknown node: zzz' : 'Choose a server: this change is made on one server at a time.')
}
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(0)
})
it('refuses a server the hub has no admin token for (403), and audits it', async () => {
await start({ B_ADMIN_TOKEN: '' })
const r = await api('admin', 'cache/flush', { params: { node: 'b' } })
expect(r.code).toBe(403)
expect(r.body.errorMessage).toMatch(/no admin token/)
expect(b.calls).toHaveLength(0)
expect((await api('admin', 'cache/flush', { params: { node: 'a' } })).body.status).toBe('ok')
expect((await auditEntries()).find((e) => e.detail === 'no admin token')).toMatchObject({ server: 'b', outcome: 'refused' })
await start()
})
it('writes only to the chosen server', async () => {
await api('admin', 'cache/flush', { params: { node: 'b' } })
expect(posts(a)).toHaveLength(0)
expect(posts(b)).toHaveLength(1)
})
it('refuses a read that has no merger when no server is named', async () => {
for (const p of ['zones/list', 'cache/list', 'blocked/list', 'dhcp/scopes/list', 'logs/list', 'dnsClient/resolve', 'zones/export']) {
const r = await api('viewer', p)
expect(r.code, p).toBe(400)
expect(r.body.errorMessage).toBe('Choose a server: this is read from one server at a time.')
expect((await api('viewer', p, { params: { node: 'cluster' } })).code, p).toBe(400)
}
expect(a.calls).toHaveLength(0)
const one = await api('viewer', 'zones/list', { params: { node: 'a', pageNumber: '1' } })
expect(one.body.response.zones[0].name).toBe('example.com')
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
})
})
describe('how the hub calls the server', () => {
it('sends the parameters in a form body and leaves only the token in the query', async () => {
const r = await api('admin', 'zones/records/add', {
params: { node: 'a', zone: 'example.com' },
...form({ domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' }),
})
expect(r.body.status).toBe('ok')
const [call] = posts(a)
expect(call.path).toBe('zones/records/add')
expect(call.token).toBe('admin-a')
expect([...call.query.keys()]).toEqual(['token'])
expect(call.contentType).toContain('application/x-www-form-urlencoded')
expect(Object.fromEntries(call.form)).toEqual({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' })
})
it('forwards a GET the console sent as a POST', async () => {
await api('admin', 'zones/enable', { params: { node: 'a', zone: 'example.com' } })
expect(posts(a)[0].form.get('zone')).toBe('example.com')
expect(posts(a)[0].form.has('node')).toBe(false)
})
it('never forwards the caller token or node, and uses the admin token alone', async () => {
await api('admin', 'cache/flush', { params: { node: 'a', token: 'evil' } })
const call = posts(a)[0]
expect(call.token).toBe('admin-a')
expect(call.form.has('token')).toBe(false)
expect(call.form.has('node')).toBe(false)
})
it('relays the server refusal as an error naming the server', async () => {
a.failWith = 'Zone already exists.'
const r = await api('admin', 'zones/create', { params: { node: 'a', zone: 'example.com', type: 'Primary' }, method: 'POST' })
expect(r.code).toBe(200)
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: Zone already exists.' })
})
it('says so when the server does not answer', async () => {
a.down = true
const r = await api('admin', 'cache/flush', { params: { node: 'a' } })
expect(r.body.status).toBe('error')
expect(r.body.errorMessage).toMatch(/^a did not answer/)
})
})
describe('the audit line', () => {
it('has the path, the server, one identifier and the outcome, and no value', async () => {
await api('operator', 'blocked/add', { params: { node: 'a' }, ...form({ domain: 'ads.example', note: 'PRIVATE-NOTE' }) })
await api('admin', 'zones/records/add', { params: { node: 'b' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'SECRET-TXT' }) })
await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ tsigKeys: 'k|TOPSECRET|hmac-sha256', proxyPassword: 'PROXYPASS2' }) })
b.failWith = 'Denied for SECRET-TXT.'
await api('admin', 'zones/delete', { params: { node: 'b', zone: 'example.com' } })
const entries = await auditEntries()
const mine = entries.filter((e) => /^(blocked|zones|settings)\./.test(e.action))
expect(mine.find((e) => e.action === 'blocked.add')).toMatchObject({ user: 'operator', role: 'operator', server: 'a', target: 'ads.example', outcome: 'ok' })
expect(mine.find((e) => e.action === 'zones.records.add')).toMatchObject({ user: 'admin', server: 'b', target: 'example.com', outcome: 'ok' })
expect(mine.find((e) => e.action === 'settings.set')).toMatchObject({ server: 'a', outcome: 'ok' })
expect(mine.find((e) => e.action === 'zones.delete')).toMatchObject({ outcome: 'failed', detail: 'refused by the server' })
expect(mine.find((e) => e.action === 'blocked.add')?.ip).toBeTruthy()
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
for (const value of ['PRIVATE-NOTE', 'SECRET-TXT', 'TOPSECRET', 'PROXYPASS2', 'admin-a', 'admin-b', 'ads.example"}']) expect(raw).not.toContain(value)
})
})
describe('settings/set', () => {
const unchanged = {
webServiceLocalAddresses: '0.0.0.0,[::]',
webServiceHttpPort: '5380',
webServiceEnableTls: 'false',
webServiceTlsCertificatePath: '',
webServiceTlsCertificatePassword: '************',
}
it('passes a save that carries the web service block unchanged, as the console sends it', async () => {
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, enableBlocking: 'false', dnsServerDomain: 'a2.example' }) })
expect(r.body.status).toBe('ok')
expect(r.body.server).toBe('a')
expect(posts(a)[0].form.get('enableBlocking')).toBe('false')
})
it.each([
['webServiceHttpPort', '5381'],
['webServiceLocalAddresses', '127.0.0.1'],
['webServiceEnableTls', 'true'],
['webServiceTlsCertificatePassword', 'new-pass'],
])('refuses a changed %s and forwards nothing', async (name, value) => {
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, [name]: value, enableBlocking: 'false' }) })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toBe("Change the web service settings in that server's own console.")
expect(posts(a)).toHaveLength(0)
const entry = (await auditEntries()).find((e) => e.action === 'settings.set' && e.outcome === 'refused')
expect(entry?.detail).toContain(name)
expect(entry?.detail).not.toContain(value)
})
it('refuses user, SSO and LDAP fields whatever their value', async () => {
for (const field of ['ssoEnabled', 'ldapServer', 'userName', 'authSessionTimeout']) {
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ [field]: 'x' }) })
expect(r.code, field).toBe(400)
}
expect(posts(a)).toHaveLength(0)
})
it('does not save when the current settings cannot be read first', async () => {
a.down = true
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ enableBlocking: 'false' }) })
expect(r.body.status).toBe('error')
expect(r.body.errorMessage).toMatch(/nothing was changed/)
})
it('compares as the console writes (lists, numbers, booleans)', () => {
const current = { webServiceLocalAddresses: ['0.0.0.0', '[::]'], webServiceHttpPort: 5380, webServiceEnableTls: false, webServiceRealIpHeader: 'X-Real-IP', other: 'x' }
expect(webServiceChanges({ webServiceLocalAddresses: '[::]\n0.0.0.0', webServiceHttpPort: '5380', webServiceEnableTls: 'False', webServiceRealIpHeader: 'X-Real-IP', other: 'changed' }, current)).toEqual([])
expect(webServiceChanges({ webServiceHttpPort: '' }, current)).toEqual(['webServiceHttpPort'])
expect(webServiceChanges({ webServiceLocalAddresses: 'false' }, current)).toEqual(['webServiceLocalAddresses'])
expect(webServiceChanges({ webServiceUnknownField: 'x' }, current)).toEqual([])
})
})
describe('settings/get', () => {
it('blanks the secrets for every role but admin', async () => {
for (const role of ['viewer', 'operator', 'apps-admin']) {
const r = await api(role, 'settings/get', { params: { node: 'a' } })
expect(r.body.status).toBe('ok')
const s = r.body.response
expect(s.tsigKeys).toEqual([{ keyName: 'k', sharedSecret: '', algorithmName: 'hmac-sha256' }])
expect(s.proxy).toMatchObject({ address: 'p', username: 'u', password: '' })
expect(s.dnsTlsCertificatePassword).toBe('')
expect(s.webServiceTlsCertificatePassword).toBe('')
expect(s.enableBlocking).toBe(true)
expect(r.text).not.toMatch(/TOPSECRET|PROXYPASS|CERTPASS/)
}
const admin = (await api('admin', 'settings/get', { params: { node: 'a' } })).body.response
expect(admin.tsigKeys[0].sharedSecret).toBe('TOPSECRET')
expect(admin.proxy.password).toBe('PROXYPASS')
})
it('also blanks the answer given without a node (the first server)', async () => {
const r = await api('viewer', 'settings/get')
expect(r.body.response.tsigKeys[0].sharedSecret).toBe('')
})
it('does not change what it is given', () => {
const input = { tsigKeys: [{ sharedSecret: 's' }], proxy: null }
expect(redactSettings(input)).toEqual({ tsigKeys: [{ sharedSecret: '' }], proxy: null })
expect(input.tsigKeys[0].sharedSecret).toBe('s')
expect(redactSettings(null)).toBeNull()
})
})
describe('dnsClient/resolve', () => {
it('is a plain read, but with import it is a zone write needing admin', async () => {
expect(effectiveRoute('dnsClient/resolve', {})).toMatchObject({ kind: 'read', capability: 'read' })
expect(effectiveRoute('dnsClient/resolve', { import: 'false' })?.kind).toBe('read')
for (const flag of ['true', 'True', ' TRUE ', '1', 'yes']) {
expect(effectiveRoute('dnsClient/resolve', { import: flag })).toMatchObject({ kind: 'write-one', capability: 'admin' })
}
expect((await api('viewer', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', type: 'A' } })).body.status).toBe('ok')
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
for (const role of ['viewer', 'operator', 'apps-admin']) {
expect((await api(role, 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).code, role).toBe(403)
}
expect(posts(a)).toHaveLength(0)
expect((await api('admin', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).body.status).toBe('ok')
expect(posts(a)[0]).toMatchObject({ token: 'admin-a' })
expect(posts(a)[0].form.get('import')).toBe('true')
})
})
describe('downloads', () => {
it('passes a file through with the reader token', async () => {
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'example.com' } })
expect(r.code).toBe(200)
expect(r.text).toBe('line one\nline two (zones/export)\n')
expect(r.headers.get('content-type')).toBe('text/plain')
expect(r.headers.get('content-disposition')).toContain('attachment')
expect(r.headers.get('x-content-type-options')).toBe('nosniff')
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
})
it('turns the server error envelope into the hub one', async () => {
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'missing.example' } })
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: No such zone: missing.example' })
})
it('opens a download with a single-use token, once', async () => {
const issued = await api('viewer', 'user/createSingleUseToken')
const once = issued.body.response.token as string
expect(once).toMatch(/^[0-9a-f]{64}$/)
const first = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
expect(first.text).toContain('logs/download')
const second = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
expect(second.body.status).toBe('invalid-token')
})
it('does not accept a single-use token for anything but a download', async () => {
const once = (await api('admin', 'user/createSingleUseToken')).body.response.token as string
const r = await api('admin', 'cache/flush', { auth: false, params: { node: 'a', token: once } })
expect(r.body.status).toBe('invalid-token')
expect(posts(a)).toHaveLength(0)
expect((await api('admin', 'zones/list', { auth: false, params: { node: 'a', token: once } })).body.status).toBe('invalid-token')
})
it('gives no single-use token without a session', async () => {
expect((await api('viewer', 'user/createSingleUseToken', { auth: false })).body.status).toBe('invalid-token')
})
})
describe('uploads and large bodies', () => {
it('forwards a pasted zone file as text/plain, with the options in the query', async () => {
const r = await api('admin', 'zones/import', {
params: { node: 'a', zone: 'example.com', overwrite: 'true' },
body: '$ORIGIN example.com.\n@ IN SOA ns hostmaster 1 2 3 4 5\n',
headers: { 'content-type': 'text/plain' },
})
expect(r.body.status).toBe('ok')
const call = posts(a)[0]
expect(call.token).toBe('admin-a')
expect(call.contentType).toContain('text/plain')
expect(call.body.toString()).toContain('$ORIGIN example.com.')
expect(call.query.get('zone')).toBe('example.com')
expect(call.query.get('overwrite')).toBe('true')
})
it('forwards a zone file sent as multipart, and a create with no body', async () => {
const data = new FormData()
data.append('fileImportZone', new Blob(['zone text'], { type: 'text/plain' }), 'example.com.zone')
const r = await api('admin', 'zones/create', { params: { node: 'b', zone: 'example.com', type: 'Primary' }, body: data })
expect(r.body.status).toBe('ok')
const call = posts(b)[0]
expect(call.contentType).toContain('multipart/form-data')
expect(call.body.toString()).toContain('zone text')
expect(call.body.toString()).toContain('name="fileImportZone"')
expect(call.query.get('zone')).toBe('example.com')
await api('admin', 'zones/create', { params: { node: 'a', zone: 'plain.example', type: 'Primary' }, method: 'POST' })
expect(posts(a)[0].form.get('zone')).toBe('plain.example')
})
it('does not take an upload from a role that may not write zones', async () => {
const r = await api('operator', 'zones/import', { params: { node: 'a', zone: 'example.com' }, body: 'x', headers: { 'content-type': 'text/plain' } })
expect(r.code).toBe(403)
expect(posts(a)).toHaveLength(0)
})
it('takes a long TXT record from a signed-in caller, and a small limit from anyone else', async () => {
const text = 'v=spf1 ' + 'a'.repeat(200_000)
const r = await api('admin', 'zones/records/add', { params: { node: 'a' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text }) })
expect(r.body.status).toBe('ok')
expect(posts(a)[0].form.get('text')).toBe(text)
// Not signed in: the small default applies (the hub drops the connection), and nothing reaches the server.
const anon = await api('admin', 'zones/records/add', { auth: false, params: { node: 'a' }, ...form({ zone: 'example.com', text }) }).catch(() => null)
expect(anon?.body.status).not.toBe('ok')
expect(posts(a)).toHaveLength(1)
})
it('takes a block list import of a few MB', async () => {
const list = Array.from({ length: 150_000 }, (_, i) => `d${i}.example`).join(',')
const r = await api('operator', 'blocked/import', { params: { node: 'a' }, ...form({ blockedZones: list }) })
expect(r.body.status).toBe('ok')
expect(posts(a)[0].form.get('blockedZones')).toBe(list)
})
})
describe('the table', () => {
it('has every area and none of the refused paths', () => {
expect(routeFor('zones/records/add')).toMatchObject({ kind: 'write-one', capability: 'admin' })
expect(routeFor('cache/flush')).toMatchObject({ kind: 'write-one', capability: 'operate' })
expect(routeFor('settings/temporaryDisableBlocking')).toMatchObject({ kind: 'write-one', capability: 'operate' })
expect(routeFor('settings/get')).toEqual({ kind: 'read', capability: 'read' })
expect(routeFor('zones/export')).toMatchObject({ kind: 'read', raw: true, perServer: true })
for (const p of ['logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll', 'settings/backup', 'settings/restore', 'admin/users/list', 'user/createToken']) {
expect(routeFor(p), p).toBeUndefined()
}
expect(safeTarget({ zone: 'example.com', text: 'x' })).toBe('example.com')
expect(safeTarget({ text: 'x' })).toBeUndefined()
})
})