The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
21 KiB
TypeScript
440 lines
21 KiB
TypeScript
// @vitest-environment node
|
|
import http from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, 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 { acceptServerConfig } from './apps-sync.ts'
|
|
import { addUser, writeUsersFile } from './users.ts'
|
|
|
|
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
|
|
|
interface Fake {
|
|
server: http.Server
|
|
url: string
|
|
apps: Record<string, { version: string; config: string | null }>
|
|
calls: { path: string; token: string; method: string }[]
|
|
rejectSet: string | null
|
|
}
|
|
|
|
function fake(): Promise<Fake> {
|
|
return new Promise((resolve) => {
|
|
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null }
|
|
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 ?? '' })
|
|
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') 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 = 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 V1 = json({ ttl: 30, db: '/base.db' })
|
|
const V2 = json({ ttl: 45, db: '/base.db' })
|
|
|
|
function reset(): void {
|
|
for (const f of [a, b]) {
|
|
f.calls = []
|
|
f.rejectSet = null
|
|
}
|
|
a.apps = { Blocker: { version: '1.0', config: V1 } }
|
|
b.apps = { Blocker: { version: '1.0', config: V1 } }
|
|
}
|
|
|
|
async function start(dir: string = dataDir): 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: dir,
|
|
HUB_STATIC: staticDir,
|
|
HUB_CACHE_SECONDS: '0',
|
|
HUB_TIMEOUT_MS: '2000',
|
|
HUB_DRIFT_MINUTES: '0',
|
|
}),
|
|
)
|
|
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 raw(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; form?: Record<string, 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)
|
|
} else if (init.form !== undefined) {
|
|
headers['content-type'] = 'application/x-www-form-urlencoded'
|
|
body = new URLSearchParams(init.form).toString()
|
|
}
|
|
return fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: body === undefined ? 'GET' : 'POST', headers, body })
|
|
}
|
|
|
|
async function api(role: string, apiPath: string, init: Parameters<typeof raw>[2] = {}) {
|
|
const res = await raw(role, apiPath, init)
|
|
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
|
}
|
|
|
|
const dirOf = (d: string = dataDir) => path.join(d, 'apps', 'Blocker')
|
|
const historyDir = (d: string = dataDir) => path.join(dirOf(d), 'history')
|
|
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
|
|
const list = async (role = 'viewer') => (await api(role, 'hub/apps/history/list', { params: { name: 'Blocker' } })).body.response.entries as Record<string, any>[]
|
|
|
|
beforeAll(async () => {
|
|
a = await fake()
|
|
b = await fake()
|
|
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-data-'))
|
|
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-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 })
|
|
rmSync(path.join(dataDir, 'drift.json'), { force: true })
|
|
await start()
|
|
})
|
|
|
|
afterEach(() => {
|
|
hub?.close()
|
|
})
|
|
|
|
const post = (role: string, p: string, body: Record<string, unknown>) => api(role, p, { json: body })
|
|
const importFrom = (server: string) => post('apps-admin', 'hub/apps/baseline/import', { name: 'Blocker', server })
|
|
const saveBaseline = (text: string) => post('apps-admin', 'hub/apps/baseline/save', { name: 'Blocker', text })
|
|
|
|
describe('history', () => {
|
|
it('keeps the old baseline text on every save and import, and lists it without the text', async () => {
|
|
await importFrom('a')
|
|
expect(await list()).toEqual([])
|
|
await saveBaseline(V2)
|
|
await importFrom('b')
|
|
const entries = await list()
|
|
expect(entries).toHaveLength(2)
|
|
expect(entries[0]).toMatchObject({ server: 'baseline', kind: 'before-save', size: V2.length })
|
|
expect(entries[0].at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/)
|
|
expect(entries[0].hash).toMatch(/^[0-9a-f]{16}$/)
|
|
expect(entries[0].text).toBeUndefined()
|
|
expect(entries[1].size).toBe(V1.length)
|
|
expect(entries[0].id > entries[1].id).toBe(true)
|
|
})
|
|
|
|
it('does not add an entry when the baseline text did not change', async () => {
|
|
await importFrom('a')
|
|
await saveBaseline(V1)
|
|
expect(await list()).toEqual([])
|
|
})
|
|
|
|
it('reads one entry by the id the listing gives and refuses anything else', async () => {
|
|
await importFrom('a')
|
|
await saveBaseline(V2)
|
|
const [entry] = await list()
|
|
const got = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })
|
|
expect(got.body.response.text).toBe(V1)
|
|
for (const id of ['../../../users.json', '..\\..\\users.json', entry.id.replace('2', '3'), entry.id + '/', '/etc/passwd', '', `../history/${entry.id}`, 'baseline.json']) {
|
|
const r = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id } })
|
|
expect([id, r.code]).toEqual([id, 400])
|
|
}
|
|
// The app name cannot walk out either.
|
|
const r = await api('viewer', 'hub/apps/history/get', { params: { name: '../..', id: 'users.json' } })
|
|
expect(r.code).toBe(400)
|
|
})
|
|
|
|
it('ignores files in the folder that are not history entries', async () => {
|
|
await importFrom('a')
|
|
mkdirSync(historyDir(), { recursive: true })
|
|
writeFileSync(path.join(historyDir(), 'notes.txt'), 'x')
|
|
expect(await list()).toEqual([])
|
|
})
|
|
|
|
it('shows what a saved version would change on a server', async () => {
|
|
await importFrom('a')
|
|
await saveBaseline(V2)
|
|
const [entry] = await list()
|
|
const r = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'b' } })
|
|
expect(r.body.response).toMatchObject({ server: 'b', writable: true, unchanged: true, current: V1 })
|
|
a.apps.Blocker.config = V2
|
|
const d = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'a' } })
|
|
expect(d.body.response.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 45, 30]])
|
|
expect(d.body.response.unchanged).toBe(false)
|
|
})
|
|
|
|
it('keeps at most 50 entries per app', async () => {
|
|
await importFrom('a')
|
|
for (let i = 0; i < 55; i++) await saveBaseline(json({ n: i }))
|
|
expect((await list()).length).toBe(50)
|
|
})
|
|
|
|
it('also records the config a single-server set replaced', async () => {
|
|
await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
|
|
const entries = await list()
|
|
expect(entries[0]).toMatchObject({ server: 'a', kind: 'before-set' })
|
|
})
|
|
})
|
|
|
|
describe('rollback and restore', () => {
|
|
async function twoVersions() {
|
|
// Both servers were changed by the hub once: their old config (V1) is in the history.
|
|
await importFrom('a')
|
|
await saveBaseline(V2)
|
|
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['a', 'b'] })
|
|
return (await list()).filter((e) => e.kind === 'before-apply')
|
|
}
|
|
|
|
it('rolls a version back to the chosen servers, one result each, history first, verified', async () => {
|
|
const applied = await twoVersions()
|
|
expect(applied.map((e) => e.server).sort()).toEqual(['a', 'b'])
|
|
expect(a.apps.Blocker.config).toBe(V2)
|
|
const entry = applied.find((e) => e.server === 'a') as Record<string, any>
|
|
b.rejectSet = 'Disk full.'
|
|
const r = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a', 'b'] })
|
|
expect(r.body.response.results).toEqual([
|
|
{ server: 'a', ok: true, message: 'Applied and verified.' },
|
|
{ server: 'b', ok: false, message: 'Disk full.' },
|
|
])
|
|
expect(a.apps.Blocker.config).toBe(V1)
|
|
expect(b.apps.Blocker.config).toBe(V2)
|
|
// The config rolled away from is kept too.
|
|
const files = readdirSync(historyDir()).filter((f) => f.endsWith('__a__before-apply.txt'))
|
|
expect(files).toHaveLength(2)
|
|
expect(writes(a).at(-1)?.token).toBe('admin-a')
|
|
})
|
|
|
|
it('refuses a rollback when the config changed since the preview, and for an old baseline entry', async () => {
|
|
const applied = await twoVersions()
|
|
const entry = applied[0]
|
|
const stale = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'], expect: { a: 'deadbeefdeadbeef' } })
|
|
expect(stale.body.response.results[0].message).toMatch(/changed on the server since the preview/)
|
|
const baselineEntry = (await list()).find((e) => e.kind === 'before-save') as Record<string, any>
|
|
const refused = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: baselineEntry.id, targets: ['a'] })
|
|
expect(refused.code).toBe(400)
|
|
expect(refused.body.errorMessage).toMatch(/Restore it as the baseline/)
|
|
})
|
|
|
|
it('restores an old baseline as the baseline, keeping the current one in the history', async () => {
|
|
await importFrom('a')
|
|
await saveBaseline(V2)
|
|
const old = (await list())[0]
|
|
const r = await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: old.id })
|
|
expect(r.body.response.baseline.text).toBe(V1)
|
|
const entries = await list()
|
|
expect(entries[0]).toMatchObject({ kind: 'before-save', size: V2.length })
|
|
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: 'nope' })).code).toBe(400)
|
|
// A server entry is not a baseline.
|
|
b.apps.Blocker.config = V2
|
|
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
|
|
const serverEntry = (await list()).find((e) => e.kind === 'before-apply') as Record<string, any>
|
|
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: serverEntry.id })).code).toBe(400)
|
|
})
|
|
|
|
it('lets a viewer look but not roll back or restore', async () => {
|
|
await twoVersions()
|
|
const [entry] = await list()
|
|
expect((await post('viewer', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'] })).code).toBe(403)
|
|
expect((await post('viewer', 'hub/apps/baseline/restore', { name: 'Blocker', id: entry.id })).code).toBe(403)
|
|
expect((await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })).code).toBe(200)
|
|
})
|
|
|
|
it('audits rollbacks and restores without any config text', async () => {
|
|
const applied = await twoVersions()
|
|
await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: applied[0].id, targets: ['a'] })
|
|
const text = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
|
expect(text).toContain('apps.history-rollback')
|
|
for (const secret of ['/base.db', 'admin-a', 'admin-b']) expect(text).not.toContain(secret)
|
|
})
|
|
})
|
|
|
|
describe('drift over the API', () => {
|
|
it('counts a drifted server in the session, shows it in drift/get and clears it when fixed', async () => {
|
|
await importFrom('a')
|
|
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
|
|
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
|
|
|
|
expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403)
|
|
const checked = await post('apps-admin', 'hub/drift/check', {})
|
|
expect(checked.body.response.apps).toMatchObject([{ kind: 'app', app: 'Blocker', name: 'Blocker', server: 'b', status: 'differs' }])
|
|
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
|
|
const got = await api('viewer', 'hub/drift/get')
|
|
expect(got.body.response.apps).toHaveLength(1)
|
|
expect(got.body.response.checking).toBe(false)
|
|
|
|
// Revert to the baseline is the sync apply on that server.
|
|
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
|
|
await post('apps-admin', 'hub/drift/check', {})
|
|
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
|
|
expect((await api('viewer', 'hub/drift/get')).body.response.apps).toEqual([])
|
|
expect(readFileSync(path.join(dataDir, 'drift.json'), 'utf8')).toContain('"apps": []')
|
|
})
|
|
|
|
it('accepts a drifted config as the baseline', async () => {
|
|
await importFrom('a')
|
|
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
|
|
const r = await post('apps-admin', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })
|
|
expect(JSON.parse(r.body.response.baseline.text)).toEqual({ ttl: 99, db: '/base.db' })
|
|
expect(r.body.response.baseline.importedFrom).toBe('b')
|
|
expect((await list())[0]).toMatchObject({ server: 'baseline', kind: 'before-save' })
|
|
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
|
|
expect((await post('viewer', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })).code).toBe(403)
|
|
})
|
|
|
|
it('on accept keeps local fields out of the baseline and moves the server values into the overrides', () => {
|
|
const out = acceptServerConfig(
|
|
json({ ttl: 99, db: '/b.db' }),
|
|
json({ ttl: 30, db: '/base.db' }),
|
|
{ paths: ['db'], values: { a: { db: '/a.db' } } },
|
|
'b',
|
|
)
|
|
expect(JSON.parse(out.baselineText)).toEqual({ ttl: 99, db: '/base.db' })
|
|
expect(out.overrides.values).toEqual({ a: { db: '/a.db' }, b: { db: '/b.db' } })
|
|
expect(() => acceptServerConfig('plain', null, { paths: ['db'], values: {} }, 'b')).toThrow(/plain text/)
|
|
})
|
|
})
|
|
|
|
describe('export and import of baselines', () => {
|
|
async function setup() {
|
|
await importFrom('a')
|
|
await post('apps-admin', 'hub/apps/overrides/save', { name: 'Blocker', paths: ['db'], values: JSON.stringify({ a: { db: '/a.db' }, b: { db: '/b.db' } }) })
|
|
}
|
|
|
|
it('exports one file with every baseline and its local fields, as a download', async () => {
|
|
await setup()
|
|
const res = await raw('apps-admin', 'hub/apps/export')
|
|
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="hub-baselines-\d{4}-\d\d-\d\d\.json"$/)
|
|
const file = (await res.json()) as Record<string, any>
|
|
expect(file).toMatchObject({ hub: 'technitium-hub', version: 1 })
|
|
expect(file.apps).toHaveLength(1)
|
|
expect(file.apps[0]).toMatchObject({ app: 'Blocker', baseline: { text: V1 }, overrides: { paths: ['db'], values: { a: { db: '/a.db' } } } })
|
|
expect((await api('viewer', 'hub/apps/export')).code).toBe(403)
|
|
expect(readFileSync(path.join(dataDir, 'audit.log'), 'utf8')).toContain('apps.export')
|
|
})
|
|
|
|
it('imports into another hub: a dry run says what is new, applying writes it, and no server is touched', async () => {
|
|
await setup()
|
|
const file = await (await raw('apps-admin', 'hub/apps/export')).text()
|
|
const other = mkdtempSync(path.join(tmpdir(), 'hub-hist-other-'))
|
|
writeUsersFile(other, [...(await import('./users.ts')).readUsersFile(dataDir)!])
|
|
try {
|
|
await start(other)
|
|
const before = a.calls.length + b.calls.length
|
|
const dry = await post('apps-admin', 'hub/apps/import', { data: file })
|
|
expect(dry.body.response).toEqual({ apps: [{ app: 'Blocker', status: 'new', ignoredServers: [] }], applied: false })
|
|
expect(readdirSync(other)).not.toContain('apps')
|
|
const done = await post('apps-admin', 'hub/apps/import', { data: file, apply: true })
|
|
expect(done.body.response.applied).toBe(true)
|
|
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'baseline.json'), 'utf8')).text).toBe(V1)
|
|
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'overrides.json'), 'utf8')).values.b).toEqual({ db: '/b.db' })
|
|
expect(a.calls.length + b.calls.length).toBe(before)
|
|
// The same file again changes nothing.
|
|
expect((await post('apps-admin', 'hub/apps/import', { data: file })).body.response.apps[0].status).toBe('same')
|
|
} finally {
|
|
rmSync(other, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('says changed, and keeps the replaced baseline in the history when applying', async () => {
|
|
await setup()
|
|
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
|
|
file.apps[0].baseline.text = V2
|
|
await saveBaseline('{"x":1}')
|
|
const dry = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
|
|
expect(dry.body.response.apps[0].status).toBe('changed')
|
|
await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file), apply: true })
|
|
expect(JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8')).text).toBe(V2)
|
|
expect((await list()).some((e) => e.kind === 'before-save' && e.size === '{"x":1}'.length)).toBe(true)
|
|
})
|
|
|
|
it('ignores local values of servers this hub does not have, and says so', async () => {
|
|
await setup()
|
|
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
|
|
file.apps[0].overrides.values.zzz = { db: '/z.db' }
|
|
const r = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
|
|
expect(r.body.response.apps[0].ignoredServers).toEqual(['zzz'])
|
|
})
|
|
|
|
it('refuses a file that is not valid, writing nothing', async () => {
|
|
await setup()
|
|
const good = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
|
|
const cases: [string, unknown][] = [
|
|
['not json', 'this is not json'],
|
|
['another file', { hello: 1 }],
|
|
['wrong version', { ...good, version: 2 }],
|
|
['no apps', { ...good, apps: 'x' }],
|
|
['bad json baseline', { ...good, apps: [{ app: 'X', baseline: { text: '{ nope' }, overrides: { paths: [], values: {} } }] }],
|
|
['no text', { ...good, apps: [{ app: 'X', baseline: {}, overrides: {} }] }],
|
|
['bad path', { ...good, apps: [{ app: 'X', baseline: { text: '{}' }, overrides: { paths: ['a b'], values: {} } }] }],
|
|
['duplicate app', { ...good, apps: [good.apps[0], good.apps[0]] }],
|
|
['text with local fields', { ...good, apps: [{ app: 'X', baseline: { text: 'plain' }, overrides: { paths: ['a'], values: {} } }] }],
|
|
]
|
|
for (const [label, content] of cases) {
|
|
const r = await post('apps-admin', 'hub/apps/import', { data: typeof content === 'string' ? content : JSON.stringify(content), apply: true })
|
|
expect([label, r.code]).toEqual([label, 400])
|
|
}
|
|
expect(readdirSync(path.join(dataDir, 'apps'))).toEqual(['Blocker'])
|
|
})
|
|
|
|
it('is for writers only, and accepts a file up to 5 MB from a signed-in caller', async () => {
|
|
const big = JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [{ app: 'Big', baseline: { text: JSON.stringify({ pad: 'x'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }, ...[1, 2, 3].map((n) => ({ app: `Big${n}`, baseline: { text: JSON.stringify({ pad: 'y'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }))] })
|
|
expect(big.length).toBeGreaterThan(2 * 1024 * 1024)
|
|
expect((await post('viewer', 'hub/apps/import', { data: big })).code).toBe(403)
|
|
const r = await post('apps-admin', 'hub/apps/import', { data: big })
|
|
expect(r.body.response.apps).toHaveLength(4)
|
|
})
|
|
})
|