The hub keeps a baseline per app in /data/apps/<app>/baseline.json. Local fields (overrides.json) are left out of the sync and out of the matrix comparison. Each server gets the baseline plus its own values. Applying goes target picker, preview with per-server diffs and warnings, then apply. Apply refuses a server whose config changed since the preview, saves the old config to history, then reads the new one back to check it. Large config bodies are read only for signed-in callers. The App config dialog has Compare, Baseline and Local fields tabs and an empty state that offers an import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
530 lines
25 KiB
TypeScript
530 lines
25 KiB
TypeScript
// @vitest-environment node
|
|
import http from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
import { parseConfig } from './config.ts'
|
|
import { createHub } from './server.ts'
|
|
import {
|
|
comparable,
|
|
diffConfigs,
|
|
effectiveConfig,
|
|
importLocalValues,
|
|
lineDiff,
|
|
parsePath,
|
|
sameConfig,
|
|
stripLocal,
|
|
validatePaths,
|
|
} from './apps-sync.ts'
|
|
import { addUser, writeUsersFile } from './users.ts'
|
|
|
|
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
|
|
|
describe('effectiveConfig', () => {
|
|
const baseline = json({ enable: true, db: { path: '/base.db', keep: 30 }, nets: [{ name: 'lan', cidr: '10.0.0.0/8' }, { name: 'wan' }] })
|
|
|
|
it('puts the server local values over the baseline, nested and in arrays', () => {
|
|
const e = effectiveConfig(baseline, { paths: ['db.path', 'nets.0.cidr'], values: { a: { 'db.path': '/a.db', 'nets.0.cidr': '192.168.0.0/16' } } }, 'a')
|
|
expect(JSON.parse(e.text)).toEqual({ enable: true, db: { path: '/a.db', keep: 30 }, nets: [{ name: 'lan', cidr: '192.168.0.0/16' }, { name: 'wan' }] })
|
|
expect(e.warnings).toEqual([])
|
|
expect(e.text).toBe(JSON.stringify(JSON.parse(e.text), null, 2))
|
|
})
|
|
|
|
it('keeps the baseline value and warns when a server has no local value', () => {
|
|
const e = effectiveConfig(baseline, { paths: ['db.path'], values: { a: {} } }, 'b')
|
|
expect(JSON.parse(e.text).db.path).toBe('/base.db')
|
|
expect(e.warnings[0]).toMatch(/No local value set for "db.path" on b/)
|
|
})
|
|
|
|
it('leaves a field absent when it is local, not in the baseline and has no value', () => {
|
|
const e = effectiveConfig(baseline, { paths: ['extra.deep'], values: {} }, 'a')
|
|
expect(JSON.parse(e.text).extra).toBeUndefined()
|
|
expect(e.warnings).toEqual([])
|
|
})
|
|
|
|
it('creates the path when the local value is for a field the baseline lacks', () => {
|
|
const e = effectiveConfig(baseline, { paths: ['extra.deep'], values: { a: { 'extra.deep': 5 } } }, 'a')
|
|
expect(JSON.parse(e.text).extra).toEqual({ deep: 5 })
|
|
})
|
|
|
|
it('uses a text baseline as it is', () => {
|
|
const e = effectiveConfig('plain = 1\nother = 2', { paths: ['a'], values: {} }, 'a')
|
|
expect(e.text).toBe('plain = 1\nother = 2')
|
|
expect(e.warnings[0]).toMatch(/plain text/)
|
|
expect(effectiveConfig('plain', { paths: [], values: {} }, 'a').warnings).toEqual([])
|
|
})
|
|
|
|
it('never writes through __proto__', () => {
|
|
const e = effectiveConfig('{}', { paths: ['__proto__.polluted'], values: { a: { '__proto__.polluted': true } } }, 'a')
|
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
|
expect(e.warnings[0]).toMatch(/does not fit/)
|
|
})
|
|
})
|
|
|
|
describe('paths', () => {
|
|
it('parses dot paths and refuses odd ones', () => {
|
|
expect(parsePath('a.b.0.c')).toEqual(['a', 'b', '0', 'c'])
|
|
expect(parsePath('0.0.0.0/0')).toEqual(['0', '0', '0', '0/0'])
|
|
for (const bad of ['', '.a', 'a..b', 'a b', 'a[0]', 'a,b']) expect(parsePath(bad)).toBeNull()
|
|
expect(() => validatePaths(['a', 'a'])).toThrow(/twice/)
|
|
expect(() => validatePaths(Array.from({ length: 51 }, (_, i) => `p${i}`))).toThrow(/50/)
|
|
expect(() => validatePaths('a')).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('comparisons that ignore local fields', () => {
|
|
const a = json({ shared: 1, db: { path: '/a' }, list: [1, 2] })
|
|
const b = json({ db: { path: '/b' }, list: [1, 2], shared: 1 })
|
|
|
|
it('treats two configs that differ only in local fields as the same', () => {
|
|
expect(sameConfig(a, b)).toBe(false)
|
|
expect(sameConfig(a, b, ['db.path'])).toBe(true)
|
|
expect(comparable(a, ['db.path'])).toBe(comparable(b, ['db.path']))
|
|
expect(comparable(a, [])).toBe(a)
|
|
})
|
|
|
|
it('removes array items from the end first', () => {
|
|
expect(stripLocal([1, 2, 3], ['0', '2'])).toEqual([2])
|
|
})
|
|
|
|
it('diffs JSON by field and leaves local fields out', () => {
|
|
const d = diffConfigs(json({ a: 1, b: 2, c: { d: 1 }, local: 1 }), json({ a: 1, b: 3, c: { d: 1, e: 2 }, f: 1, local: 2 }), ['local'])
|
|
expect(d).toEqual({
|
|
kind: 'json',
|
|
changes: [
|
|
{ path: 'b', type: 'changed', a: 2, b: 3 },
|
|
{ path: 'c.e', type: 'added', a: undefined, b: 2 },
|
|
{ path: 'f', type: 'added', a: undefined, b: 1 },
|
|
],
|
|
})
|
|
expect(diffConfigs(json({ x: 1 }), json({}), []).kind).toBe('json')
|
|
expect((diffConfigs(json({ x: 1 }), json({}), []) as { changes: { type: string }[] }).changes[0].type).toBe('removed')
|
|
})
|
|
|
|
it('diffs text by line', () => {
|
|
const d = diffConfigs('one\ntwo\nthree', 'one\n2\nthree\nfour')
|
|
expect(d.kind).toBe('text')
|
|
expect(d).toMatchObject({ changed: 3, truncated: false })
|
|
expect((d as { lines: { op: string; text: string }[] }).lines).toEqual([
|
|
{ op: 'same', text: 'one' },
|
|
{ op: 'del', text: 'two' },
|
|
{ op: 'add', text: '2' },
|
|
{ op: 'same', text: 'three' },
|
|
{ op: 'add', text: 'four' },
|
|
])
|
|
})
|
|
|
|
it('does not build a table for a huge text', () => {
|
|
const big = Array.from({ length: 2500 }, (_, i) => `line ${i}`).join('\n')
|
|
expect(lineDiff(big, big + '\nx')).toMatchObject({ truncated: true, lines: [] })
|
|
})
|
|
})
|
|
|
|
describe('importLocalValues', () => {
|
|
it('reads each server own value of each path', () => {
|
|
const values = importLocalValues({ a: json({ db: { path: '/a' } }), b: json({ db: { path: '/b' } }), c: 'not json', d: null }, ['db.path', 'missing'])
|
|
expect(values).toEqual({ a: { 'db.path': '/a' }, b: { 'db.path': '/b' }, c: {}, d: {} })
|
|
})
|
|
})
|
|
|
|
/* ---- With fake servers ---- */
|
|
|
|
interface Fake {
|
|
server: http.Server
|
|
url: string
|
|
apps: Record<string, { version: string; config: string | null }>
|
|
calls: { path: string; token: string; method: string; form: URLSearchParams }[]
|
|
rejectSet: string | null
|
|
mangleSet: boolean
|
|
denyRead: boolean
|
|
}
|
|
|
|
function fake(): Promise<Fake> {
|
|
return new Promise((resolve) => {
|
|
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null, mangleSet: false, denyRead: false }
|
|
f.server.on('request', (req, res) => {
|
|
const chunks: Buffer[] = []
|
|
req.on('data', (c: Buffer) => chunks.push(c))
|
|
req.on('end', () => {
|
|
const url = new URL(req.url ?? '/', 'http://fake')
|
|
const apiPath = url.pathname.replace(/^\/api\//, '')
|
|
const token = url.searchParams.get('token') ?? ''
|
|
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
|
f.calls.push({ path: apiPath, token, method: req.method ?? '', form })
|
|
const name = url.searchParams.get('name') ?? form.get('name') ?? ''
|
|
res.setHeader('content-type', 'application/json')
|
|
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
|
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
|
|
if (apiPath === 'apps/list') return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, version: a.version })) })
|
|
if (apiPath === 'apps/config/get') {
|
|
if (f.denyRead && !token.startsWith('admin-')) return error('Access was denied.')
|
|
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
|
|
}
|
|
if (apiPath === 'apps/config/set') {
|
|
if (!token.startsWith('admin-')) return error('Access was denied.')
|
|
if (f.rejectSet !== null) return error(f.rejectSet)
|
|
f.apps[name].config = f.mangleSet ? '{"mangled":true}' : form.get('config')
|
|
return ok()
|
|
}
|
|
ok()
|
|
})
|
|
})
|
|
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 CFG_A = json({ enable: true, ttl: 30, dbPath: '/data/a.db' })
|
|
const CFG_B = json({ enable: true, ttl: 60, dbPath: '/data/b.db' })
|
|
|
|
function reset(): void {
|
|
a.calls = []
|
|
b.calls = []
|
|
for (const f of [a, b]) {
|
|
f.rejectSet = null
|
|
f.mangleSet = false
|
|
f.denyRead = false
|
|
}
|
|
a.apps = { Blocker: { version: '1.0', config: CFG_A } }
|
|
b.apps = { Blocker: { version: '1.0', config: CFG_B } }
|
|
}
|
|
|
|
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 ['viewer', 'apps-admin', 'admin']) {
|
|
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
|
|
}
|
|
}
|
|
|
|
async function api(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; method?: string } = {}) {
|
|
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}` }
|
|
let body: string | undefined
|
|
if (init.json !== undefined) {
|
|
headers['content-type'] = 'application/json'
|
|
body = JSON.stringify(init.json)
|
|
}
|
|
const res = await fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: init.method ?? (body === undefined ? 'GET' : 'POST'), headers, body })
|
|
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
|
}
|
|
|
|
const dirOf = () => path.join(dataDir, 'apps', 'Blocker')
|
|
const history = () => readdirSync(path.join(dirOf(), 'history'))
|
|
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
|
|
|
|
beforeAll(async () => {
|
|
a = await fake()
|
|
b = await fake()
|
|
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-sync-data-'))
|
|
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-sync-static-'))
|
|
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
|
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
|
users = addUser(users, 'admin', 'admin', 'password-admin')
|
|
writeUsersFile(dataDir, users)
|
|
})
|
|
|
|
afterAll(() => {
|
|
hub?.close()
|
|
a.server.close()
|
|
b.server.close()
|
|
rmSync(dataDir, { recursive: true, force: true })
|
|
rmSync(staticDir, { recursive: true, force: true })
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
reset()
|
|
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
|
|
rmSync(path.join(dataDir, 'audit.log'), { force: true })
|
|
await start()
|
|
})
|
|
|
|
afterEach(() => {
|
|
hub?.close()
|
|
})
|
|
|
|
const importFrom = (server: string) => api('apps-admin', 'hub/apps/baseline/import', { json: { name: 'Blocker', server } })
|
|
const saveOverrides = (paths: string[], values: Record<string, Record<string, unknown>>) => api('apps-admin', 'hub/apps/overrides/save', { json: { name: 'Blocker', paths, values } })
|
|
|
|
describe('baseline and local fields', () => {
|
|
it('has no baseline at first, and compare shows each server live config', async () => {
|
|
const r = await api('viewer', 'hub/apps/config/compare', { params: { name: 'Blocker' } })
|
|
expect(r.body.response.baseline).toBeNull()
|
|
const [sa, sb] = r.body.response.servers
|
|
expect(sa).toMatchObject({ id: 'a', installed: true, writable: true, version: '1.0', inSync: null, text: CFG_A })
|
|
expect(sb.text).toBe(CFG_B)
|
|
expect(sa.vsOthers.b.changes.map((c: any) => c.path)).toEqual(['ttl', 'dbPath'])
|
|
})
|
|
|
|
it('imports a baseline from a server and stores it as text', async () => {
|
|
const r = await importFrom('a')
|
|
expect(r.body.response.baseline).toMatchObject({ app: 'Blocker', text: CFG_A, importedFrom: 'a', updatedBy: 'apps-admin' })
|
|
const file = JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8'))
|
|
expect(file.text).toBe(CFG_A)
|
|
const cmp = (await api('viewer', 'hub/apps/config/compare', { params: { name: 'Blocker' } })).body.response
|
|
expect(cmp.servers[0].inSync).toBe(true)
|
|
expect(cmp.servers[1].inSync).toBe(false)
|
|
expect(cmp.servers[1].vsBaseline.changes.map((c: any) => c.path)).toEqual(['ttl', 'dbPath'])
|
|
})
|
|
|
|
it('refuses an import from a server with no config', async () => {
|
|
a.apps.Blocker.config = null
|
|
const r = await importFrom('a')
|
|
expect(r.code).toBe(400)
|
|
expect(r.body.errorMessage).toMatch(/no config/)
|
|
})
|
|
|
|
it('validates a JSON baseline and reports the parse error', async () => {
|
|
const bad = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: '{ "a": 1,, }' } })
|
|
expect(bad.code).toBe(400)
|
|
expect(bad.body.errorMessage).toMatch(/not valid JSON: .*position/)
|
|
const text = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'plain = text' } })
|
|
expect(text.body.response.baseline.text).toBe('plain = text')
|
|
})
|
|
|
|
it('limits the baseline to 1 MB', async () => {
|
|
const r = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'x'.repeat(1024 * 1024 + 1) } })
|
|
expect(r.code).toBe(400)
|
|
expect(r.body.errorMessage).toMatch(/1 MB/)
|
|
})
|
|
|
|
it('saves local fields, rejects bad paths and a text baseline', async () => {
|
|
await importFrom('a')
|
|
const ok = await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } })
|
|
expect(ok.body.response.overrides).toEqual({ paths: ['dbPath'], values: { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } } })
|
|
expect((await saveOverrides(['a b'], {})).code).toBe(400)
|
|
expect((await saveOverrides(['dbPath'], { zzz: {} })).code).toBe(400)
|
|
await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'plain' } })
|
|
const refused = await saveOverrides(['dbPath'], {})
|
|
expect(refused.body.errorMessage).toMatch(/plain text/)
|
|
})
|
|
|
|
it('fills local values from the current server configs', async () => {
|
|
const r = await api('apps-admin', 'hub/apps/overrides/current', { json: { name: 'Blocker', paths: ['dbPath', 'ttl'] } })
|
|
expect(r.body.response.values).toEqual({ a: { dbPath: '/data/a.db', ttl: 30 }, b: { dbPath: '/data/b.db', ttl: 60 } })
|
|
})
|
|
|
|
it('makes the matrix say Same when servers differ only in local fields', async () => {
|
|
const before = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
|
expect(before.configs).toBe('different')
|
|
expect(before.diffCount).toBe(2)
|
|
await saveOverrides(['ttl', 'dbPath'], {})
|
|
const after = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
|
expect(after.configs).toBe('same')
|
|
expect(after.servers.a.configHash).toBe(after.servers.b.configHash)
|
|
await saveOverrides(['dbPath'], {})
|
|
const some = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
|
expect(some).toMatchObject({ configs: 'different', diffCount: 1 })
|
|
})
|
|
|
|
it('refuses writers-only calls to a viewer', async () => {
|
|
for (const [p, json] of [
|
|
['hub/apps/baseline/import', { name: 'Blocker', server: 'a' }],
|
|
['hub/apps/baseline/save', { name: 'Blocker', text: '{}' }],
|
|
['hub/apps/overrides/save', { name: 'Blocker', paths: [], values: {} }],
|
|
['hub/apps/config/preview', { name: 'Blocker', targets: ['a'] }],
|
|
['hub/apps/config/apply', { name: 'Blocker', targets: ['a'] }],
|
|
] as const) {
|
|
const r = await api('viewer', p, { json })
|
|
expect([p, r.code]).toEqual([p, 403])
|
|
}
|
|
expect(writes(a)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('preview and apply', () => {
|
|
async function setup() {
|
|
await importFrom('a')
|
|
await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } })
|
|
}
|
|
|
|
it('previews what would change on each target, with the local values in place', async () => {
|
|
await setup()
|
|
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a', 'b'] } })
|
|
const [pa, pb] = r.body.response.targets
|
|
expect(pa).toMatchObject({ server: 'a', ok: true, unchanged: true, writable: true })
|
|
expect(pb).toMatchObject({ server: 'b', ok: true, unchanged: false, current: CFG_B })
|
|
expect(pb.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 60, 30]])
|
|
expect(JSON.parse(pb.effective)).toEqual({ enable: true, ttl: 30, dbPath: '/data/b.db' })
|
|
expect(pb.currentHash).toMatch(/^[0-9a-f]{16}$/)
|
|
})
|
|
|
|
it('warns about a missing local value, a version difference and a server without a token', async () => {
|
|
await importFrom('a')
|
|
await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' } })
|
|
b.apps.Blocker.version = '0.9'
|
|
await start({ B_ADMIN_TOKEN: '' })
|
|
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['b'] } })
|
|
const warnings: string[] = r.body.response.targets[0].warnings
|
|
expect(r.body.response.targets[0].writable).toBe(false)
|
|
expect(warnings.some((w) => /No local value set for "dbPath" on b/.test(w))).toBe(true)
|
|
expect(warnings.some((w) => /align versions first/.test(w))).toBe(true)
|
|
expect(warnings.some((w) => /no admin token/.test(w))).toBe(true)
|
|
})
|
|
|
|
it('needs a baseline', async () => {
|
|
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a'] } })
|
|
expect(r.code).toBe(400)
|
|
expect(r.body.errorMessage).toMatch(/no baseline/)
|
|
})
|
|
|
|
it('applies: saves the old config, writes with the admin token, verifies', async () => {
|
|
await setup()
|
|
const preview = (await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a', 'b'] } })).body.response.targets
|
|
const expectHashes = Object.fromEntries(preview.map((t: any) => [t.server, t.currentHash]))
|
|
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'], expect: expectHashes } })
|
|
const results = r.body.response.results
|
|
expect(results.find((x: any) => x.server === 'a')).toMatchObject({ ok: true, message: expect.stringMatching(/Already in sync/) })
|
|
expect(results.find((x: any) => x.server === 'b')).toEqual({ server: 'b', ok: true, message: 'Applied and verified.' })
|
|
expect(JSON.parse(b.apps.Blocker.config as string)).toEqual({ enable: true, ttl: 30, dbPath: '/data/b.db' })
|
|
expect(writes(a)).toHaveLength(0)
|
|
const [w] = writes(b)
|
|
expect(w.token).toBe('admin-b')
|
|
expect(w.method).toBe('POST')
|
|
expect(w.form.get('name')).toBe('Blocker')
|
|
const files = history()
|
|
expect(files).toHaveLength(1)
|
|
expect(files[0]).toMatch(/__b__before-apply\.txt$/)
|
|
expect(readFileSync(path.join(dirOf(), 'history', files[0]), 'utf8')).toBe(CFG_B)
|
|
expect((await api('viewer', 'hub/apps/matrix')).body.response.apps[0].configs).toBe('same')
|
|
})
|
|
|
|
it('refuses a target whose config changed since the preview', async () => {
|
|
await setup()
|
|
const preview = (await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['b'] } })).body.response.targets
|
|
b.apps.Blocker.config = json({ enable: false })
|
|
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'], expect: { b: preview[0].currentHash } } })
|
|
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
|
|
expect(r.body.response.results[0].message).toMatch(/changed on the server since the preview/)
|
|
expect(writes(b)).toHaveLength(0)
|
|
expect(b.apps.Blocker.config).toBe(json({ enable: false }))
|
|
})
|
|
|
|
it('reports a server that refuses the config and leaves it as it was', async () => {
|
|
await setup()
|
|
b.rejectSet = 'The config is invalid.'
|
|
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
|
expect(r.body.response.results[0]).toEqual({ server: 'b', ok: false, message: 'The config is invalid.' })
|
|
expect(b.apps.Blocker.config).toBe(CFG_B)
|
|
})
|
|
|
|
it('handles one target failing and the other succeeding', async () => {
|
|
await setup()
|
|
a.apps.Blocker.config = json({ enable: false })
|
|
b.rejectSet = 'Disk full.'
|
|
const r = await api('admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'] } })
|
|
expect(r.body.response.results.map((x: any) => [x.server, x.ok])).toEqual([['a', true], ['b', false]])
|
|
expect(JSON.parse(a.apps.Blocker.config as string).ttl).toBe(30)
|
|
})
|
|
|
|
it('says so when the server reads back something else', async () => {
|
|
await setup()
|
|
b.mangleSet = true
|
|
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
|
expect(r.body.response.results[0].ok).toBe(false)
|
|
expect(r.body.response.results[0].message).toMatch(/reads back a different one/)
|
|
})
|
|
|
|
it('does not write to a server without an admin token', async () => {
|
|
await setup()
|
|
await start({ B_ADMIN_TOKEN: '' })
|
|
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
|
expect(r.body.response.results[0]).toEqual({ server: 'b', ok: false, message: 'no admin token configured' })
|
|
expect(writes(b)).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps config texts out of the audit log', async () => {
|
|
await setup()
|
|
await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: '{ "bad": ,, "secret-baseline-text": 1 }' } })
|
|
await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'] } })
|
|
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
|
for (const text of ['/data/a.db', '/data/b.db', 'secret-baseline-text', 'admin-a', 'admin-b']) expect(raw).not.toContain(text)
|
|
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '50' } })).body.response.entries as Record<string, string>[]
|
|
const applied = entries.filter((e) => e.action === 'apps.config-apply')
|
|
expect(applied).toHaveLength(2)
|
|
expect(applied.find((e) => e.server === 'b')).toMatchObject({ user: 'apps-admin', target: 'Blocker', outcome: 'ok' })
|
|
expect(applied.find((e) => e.server === 'b')?.detail).toMatch(/[0-9a-f]{16} to [0-9a-f]{16}/)
|
|
expect(entries.some((e) => e.action === 'apps.baseline-import' && e.outcome === 'ok')).toBe(true)
|
|
expect(entries.some((e) => e.action === 'apps.baseline-save' && e.outcome === 'refused' && e.detail === 'invalid baseline')).toBe(true)
|
|
})
|
|
|
|
it('keeps at most 50 history files', async () => {
|
|
await setup()
|
|
mkdirSync(path.join(dirOf(), 'history'), { recursive: true })
|
|
for (let i = 0; i < 55; i++) writeFileSync(path.join(dirOf(), 'history', `2020-01-01T00-00-${String(i).padStart(2, '0')}.000Z__b__before-apply.txt`), 'old')
|
|
await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
|
expect(history()).toHaveLength(50)
|
|
})
|
|
})
|
|
|
|
describe('single-server config', () => {
|
|
it('reads a config from one node with the read token and needs a node', async () => {
|
|
const r = await api('viewer', 'apps/config/get', { params: { node: 'a', name: 'Blocker' } })
|
|
expect(r.body.response.config).toBe(CFG_A)
|
|
expect(a.calls.at(-1)?.token).toBe('read-a')
|
|
expect((await api('viewer', 'apps/config/get', { params: { name: 'Blocker' } })).code).toBe(400)
|
|
expect((await api('viewer', 'apps/config/get', { params: { node: 'cluster', name: 'Blocker' } })).code).toBe(400)
|
|
})
|
|
|
|
it('sets a config on one node with the admin token, saving the old one first', async () => {
|
|
const r = await api('apps-admin', 'apps/config/set', {
|
|
params: { node: 'b' },
|
|
method: 'POST',
|
|
json: { name: 'Blocker', config: '{"enable":false}' },
|
|
})
|
|
expect(r.body.status).toBe('ok')
|
|
const [w] = writes(b)
|
|
expect(w.token).toBe('admin-b')
|
|
expect(b.apps.Blocker.config).toBe('{"enable":false}')
|
|
expect(history()).toHaveLength(1)
|
|
expect(readFileSync(path.join(dirOf(), 'history', history()[0]), 'utf8')).toBe(CFG_B)
|
|
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
|
expect(raw).toContain('apps.config-set')
|
|
expect(raw).not.toContain('"enable":false')
|
|
})
|
|
|
|
it('refuses the write without a node, to a viewer, and when the old config cannot be saved', async () => {
|
|
expect((await api('apps-admin', 'apps/config/set', { json: { name: 'Blocker', config: '{}' } })).code).toBe(400)
|
|
expect((await api('viewer', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })).code).toBe(403)
|
|
a.denyRead = true
|
|
a.apps.Blocker.config = 'unchanged'
|
|
// The admin token can still read, so make the read fail by removing the app.
|
|
delete a.apps.Blocker
|
|
const r = await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
|
|
expect(r.body.status).toBe('error')
|
|
expect(r.body.errorMessage).toMatch(/nothing was changed/)
|
|
expect(writes(a)).toHaveLength(0)
|
|
})
|
|
})
|