// @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 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 { 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 = {} const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] async function start(extra: Record = {}): Promise { 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((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 method?: string body?: string | FormData headers?: Record auth?: boolean } async function api(role: string, apiPath: string, init: Init = {}) { const query = new URLSearchParams(init.params ?? {}) const headers: Record = { ...(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 = {} try { json = JSON.parse(text) as Record } 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) => ({ body: new URLSearchParams(params).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' }, }) async function auditEntries(): Promise[]> { 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', {}], // The blocking switch alone is blocking, not server settings (routes.ts `effectiveRoute`). ['settings/set', { enableBlocking: 'false' }], ] 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', { dnsServerDomain: 'x.example' }], // The switch plus any other field is a settings change. ['settings/set', { enableBlocking: 'true', dnsServerDomain: 'x.example' }], ] 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() }) })