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 <[email protected]>
This commit is contained in:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
@@ -0,0 +1,596 @@
|
||||
// @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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user