// @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).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: 'remove', 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 calls: { path: string; token: string; method: string; form: URLSearchParams }[] rejectSet: string | null mangleSet: boolean denyRead: boolean } function fake(): Promise { 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 = {} 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 = {}): 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 ['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; json?: unknown; method?: string } = {}) { const headers: Record = { 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 } } 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>) => 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[] 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) }) })