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>
597 lines
28 KiB
TypeScript
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()
|
|
})
|
|
})
|