Files
technitium-console/hub/apps-write.test.ts
T
DenozordecandClaude Opus 5.5 8bb1e01936 Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:06:37 +07:00

518 lines
24 KiB
TypeScript

// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { existsSync, 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 { boundaryOf, parseMultipart } from './multipart.ts'
import { safeName } from './apps-write.ts'
import { addUser, writeUsersFile } from './users.ts'
interface Call {
path: string
token: string
method: string
query: URLSearchParams
body: Buffer
contentType: string
}
interface Fake {
server: http.Server
url: string
apps: Record<string, { version: string; config: string | null }>
store: { name: string; version: string; url: string; description: string; size: string }[]
calls: Call[]
failWrites: boolean
denyConfig: boolean
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', apps: {}, store: [], calls: [], failWrites: false, denyConfig: 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 contentType = String(req.headers['content-type'] ?? '')
// Technitium reads query or form alike; `query` here is both (`token` is the only one the hub leaves in the URL).
const query = new URLSearchParams(url.searchParams)
if (contentType.includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(Buffer.concat(chunks).toString('utf8'))) query.set(k, v)
const name = query.get('name') ?? ''
f.calls.push({ path: apiPath, token, method: req.method ?? '', query, body: Buffer.concat(chunks), contentType })
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 }))
const isWrite = ['apps/downloadAndInstall', 'apps/downloadAndUpdate', 'apps/uninstall', 'apps/install', 'apps/update'].includes(apiPath)
if (isWrite && !token.startsWith('admin-')) return error("Access was denied to user 'reader' for apps.")
if (isWrite && f.failWrites) return error('Disk full.')
switch (apiPath) {
case 'apps/list':
return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, description: `${n} d`, version: a.version })) })
case 'apps/listStoreApps':
return ok({ storeApps: f.store })
case 'apps/config/get':
if (f.denyConfig) return error('Access was denied.')
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
case 'apps/downloadAndInstall':
case 'apps/downloadAndUpdate': {
const entry = f.store.find((s) => s.name === name && s.url === query.get('url'))
if (!entry) return error('not in store')
f.apps[name] = { version: entry.version, config: f.apps[name]?.config ?? null }
return ok()
}
case 'apps/uninstall':
delete f.apps[name]
return ok()
case 'apps/install':
case 'apps/update':
f.apps[name] = { version: 'zip', config: null }
return ok()
default:
return 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 STORE = (v: string) => [{ name: 'Blocker', version: v, url: `https://store.example/Blocker-v${v}.zip`, description: 'd', size: '1 KB' }]
function reset(): void {
for (const f of [a, b]) {
f.calls = []
f.failWrites = false
f.denyConfig = false
f.store = STORE('2.0')
f.apps = { Blocker: { version: '1.0', config: '{"secret":"cfg"}' } }
}
}
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', 'operator', '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>; method?: string; json?: unknown; body?: string | FormData; headers?: Record<string, string> } = {}) {
const query = new URLSearchParams(init.params ?? {})
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}`, ...(init.headers ?? {}) }
let body = init.body
if (init.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(init.json)
}
const res = await fetch(`${base}/api/${apiPath}?${query}`, { method: init.method ?? (body === undefined ? 'GET' : 'POST'), headers, body })
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const run = (role: string, json: Record<string, unknown>) => api(role, 'hub/apps/run', { json })
function zipForm(extra: Record<string, string>, bytes: Uint8Array): FormData {
const form = new FormData()
for (const [k, v] of Object.entries(extra)) form.append(k, v)
form.append('fileAppZip', new Blob([bytes], { type: 'application/zip' }), 'Blocker.zip')
return form
}
const writes = (f: Fake) => f.calls.filter((c) => c.path.startsWith('apps/') && c.method === 'POST')
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-write-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-write-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)
})
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()
})
describe('multipart parser', () => {
const boundary = '----testBoundary123'
const type = `multipart/form-data; boundary=${boundary}`
it('reads fields and a binary file, whatever bytes it holds', () => {
const binary = Buffer.alloc(256)
for (let i = 0; i < 256; i++) binary[i] = i
const tricky = Buffer.concat([binary, Buffer.from(`\r\n--${boundary.slice(0, 5)}\r\n\r\n--`), binary])
const body = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="name"\r\n\r\nBlocker\r\n`),
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="fileAppZip"; filename="a b.zip"\r\nContent-Type: application/zip\r\n\r\n`),
tricky,
Buffer.from(`\r\n--${boundary}--\r\n`),
])
const parsed = parseMultipart(body, type)
expect(parsed.fields).toEqual({ name: 'Blocker' })
expect(parsed.files).toHaveLength(1)
expect(parsed.files[0]).toMatchObject({ field: 'fileAppZip', filename: 'a b.zip', type: 'application/zip' })
expect(parsed.files[0].data.equals(tricky)).toBe(true)
})
it('reads a quoted boundary and an empty file', () => {
expect(boundaryOf('multipart/form-data; boundary="x y"')).toBe('x y')
const body = Buffer.from(`--q\r\nContent-Disposition: form-data; name="f"; filename="e.zip"\r\n\r\n\r\n--q--`)
expect(parseMultipart(body, 'multipart/form-data; boundary=q').files[0].data.length).toBe(0)
})
it('refuses what is not multipart', () => {
expect(() => parseMultipart(Buffer.from('x'), 'text/plain')).toThrow()
expect(() => parseMultipart(Buffer.from('nothing here'), type)).toThrow(/boundary/)
expect(() => parseMultipart(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="a"\r\n\r\nno end`), type)).toThrow(/terminated/)
})
it('names folders safely', () => {
expect(safeName('Blocker')).toBe('Blocker')
expect(safeName('Query Logs (Sqlite)')).toMatch(/^Query_Logs__Sqlite_-[0-9a-f]{6}$/)
expect(safeName('../../etc')).not.toContain('/')
expect(safeName('..')).not.toMatch(/^\./)
})
})
describe('roles', () => {
it('refuses viewer and operator on every write and audits it', async () => {
for (const role of ['viewer', 'operator']) {
const r = await run(role, { action: 'uninstall', name: 'Blocker', targets: ['a'] })
expect(r.code).toBe(403)
expect(r.body).toEqual({ status: 'error', errorMessage: 'Your role does not allow this.' })
expect((await api(role, 'apps/uninstall', { params: { name: 'Blocker', node: 'a' } })).code).toBe(403)
expect((await api(role, 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'X', targets: 'a' }, new Uint8Array([1])) })).code).toBe(403)
}
expect(writes(a)).toHaveLength(0)
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '100' } })).body.response.entries as Record<string, string>[]
expect(entries.filter((e) => e.action === 'denied-write' && e.user === 'viewer').length).toBeGreaterThanOrEqual(3)
expect(entries.some((e) => e.action === 'denied-write' && e.user === 'operator' && e.target === 'hub/apps/run')).toBe(true)
})
it('lets apps-admin and admin write', async () => {
for (const role of ['apps-admin', 'admin']) {
b.apps = a.apps = { Blocker: { version: '1.0', config: null } }
const r = await run(role, { action: 'update-store', name: 'Blocker', targets: ['a'] })
expect(r.body.status).toBe('ok')
expect(r.body.response.results).toEqual([{ server: 'a', ok: true, message: 'Updated from the store.' }])
}
})
it('does not let a write endpoint be called with GET', async () => {
expect((await api('admin', 'hub/apps/run', { method: 'GET', params: { action: 'uninstall', name: 'Blocker', targets: 'a' } })).code).toBe(405)
expect(writes(a)).toHaveLength(0)
})
it('tells the session who may write what', async () => {
const viewer = (await api('viewer', 'user/session/get')).body.info
const admin = (await api('apps-admin', 'user/session/get')).body.info
expect(viewer.permissions.Apps).toEqual({ canView: true, canModify: false, canDelete: false })
expect(admin.permissions.Apps).toEqual({ canView: true, canModify: true, canDelete: true })
expect(admin.permissions.Zones.canModify).toBe(false)
expect(admin.hubWritable).toEqual(['a', 'b'])
await start({ B_ADMIN_TOKEN: '' })
expect((await api('viewer', 'user/session/get')).body.info.hubWritable).toEqual(['a'])
})
})
describe('hub/apps/run', () => {
it('uses only the admin token and the URL of the target own store', async () => {
a.store = STORE('2.0')
b.store = [{ ...STORE('2.5')[0], url: 'https://other.example/b.zip' }]
const r = await run('admin', { action: 'update-store', name: 'Blocker', targets: ['a', 'b'] })
expect(r.body.response.results.map((x: any) => x.ok)).toEqual([true, true])
const [wa] = writes(a)
const [wb] = writes(b)
expect(wa.token).toBe('admin-a')
expect(wa.query.get('url')).toBe('https://store.example/Blocker-v2.0.zip')
expect(wb.token).toBe('admin-b')
expect(wb.query.get('url')).toBe('https://other.example/b.zip')
expect(a.calls.filter((c) => c.token === 'read-a').every((c) => !c.path.includes('downloadAnd'))).toBe(true)
})
it('refuses a url that is not from the store listing, and an app the store does not have', async () => {
const r = await run('admin', { action: 'install-store', name: 'Blocker', url: 'https://evil.example/x.zip', targets: ['a'] })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toMatch(/store listing/)
expect(writes(a)).toHaveLength(0)
const same = await run('admin', { action: 'update-store', name: 'Blocker', url: STORE('2.0')[0].url, targets: ['a'] })
expect(same.body.status).toBe('ok')
const missing = await run('admin', { action: 'install-store', name: 'Nope', targets: ['a'] })
expect(missing.body.response.results[0]).toMatchObject({ server: 'a', ok: false })
expect(missing.body.response.results[0].message).toMatch(/not in this server/)
})
it('refuses unknown servers, no targets and bad actions', async () => {
expect((await run('admin', { action: 'update-store', name: 'Blocker', targets: ['zzz'] })).code).toBe(400)
expect((await run('admin', { action: 'update-store', name: 'Blocker', targets: [] })).code).toBe(400)
expect((await run('admin', { action: 'format', name: 'Blocker', targets: ['a'] })).code).toBe(400)
expect((await run('admin', { action: 'uninstall', name: '', targets: ['a'] })).code).toBe(400)
})
it('accepts a form body with comma separated targets', async () => {
const r = await api('admin', 'hub/apps/run', {
body: new URLSearchParams({ action: 'update-store', name: 'Blocker', targets: 'a,b' }).toString(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
})
expect(r.body.response.results).toHaveLength(2)
})
it('refuses a server without an admin token, without calling it', async () => {
await start({ B_ADMIN_TOKEN: '' })
const r = await run('admin', { action: 'update-store', name: 'Blocker', targets: ['a', 'b'] })
const results = r.body.response.results
expect(results.find((x: any) => x.server === 'a').ok).toBe(true)
expect(results.find((x: any) => x.server === 'b')).toEqual({ server: 'b', ok: false, message: 'no admin token configured' })
expect(writes(b)).toHaveLength(0)
expect(b.calls.every((c) => c.token === 'read-b')).toBe(true)
const m = await api('admin', 'hub/apps/matrix')
expect(m.body.response.servers).toEqual([{ id: 'a', ok: true, writable: true }, { id: 'b', ok: true, writable: false }])
})
it('reports each target on its own when one fails, and audits every result', async () => {
b.failWrites = true
const r = await run('apps-admin', { action: 'update-store', name: 'Blocker', targets: ['a', 'b'] })
expect(r.body.response.results).toEqual([
{ server: 'a', ok: true, message: 'Updated from the store.' },
{ server: 'b', ok: false, message: 'Disk full.' },
])
expect(a.apps.Blocker.version).toBe('2.0')
expect(b.apps.Blocker.version).toBe('1.0')
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '20' } })).body.response.entries as Record<string, string>[]
const mine = entries.filter((e) => e.action === 'apps.update-store')
expect(mine).toHaveLength(2)
expect(mine.find((e) => e.server === 'a')).toMatchObject({ user: 'apps-admin', role: 'apps-admin', target: 'Blocker', outcome: 'ok' })
expect(mine.find((e) => e.server === 'b')).toMatchObject({ outcome: 'failed', detail: 'Disk full.' })
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
for (const secret of ['admin-a', 'admin-b', 'read-a', 'password-apps-admin', '"secret":"cfg"']) expect(raw).not.toContain(secret)
})
it('shows the new version in the matrix after an update (the cache is dropped)', async () => {
await start({ HUB_CACHE_SECONDS: '60' })
const before = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
expect(before.servers.a.version).toBe('1.0')
await run('admin', { action: 'update-store', name: 'Blocker', targets: ['a'] })
const after = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
expect(after.servers.a.version).toBe('2.0')
expect(after.servers.b.version).toBe('1.0')
expect(after.versions).toBe('different')
})
})
describe('uninstall and its history', () => {
const history = () => path.join(dataDir, 'apps', 'Blocker', 'history')
it('saves the config first, then uninstalls', async () => {
const r = await run('admin', { action: 'uninstall', name: 'Blocker', targets: ['a', 'b'] })
expect(r.body.response.results.map((x: any) => x.ok)).toEqual([true, true])
expect(a.apps.Blocker).toBeUndefined()
const files = readdirSync(history()).sort()
expect(files).toHaveLength(2)
expect(files.some((f) => /^\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d\.\d{3}Z__a__before-uninstall\.txt$/.test(f))).toBe(true)
expect(readFileSync(path.join(history(), files[0]), 'utf8')).toBe('{"secret":"cfg"}')
expect(readdirSync(history()).some((f) => f.endsWith('.tmp'))).toBe(false)
})
it('keeps at most 50 files per app, dropping the oldest', async () => {
mkdirSync(history(), { recursive: true })
for (let i = 0; i < 55; i++) writeFileSync(path.join(history(), `2020-01-01T00-00-${String(i).padStart(2, '0')}.000Z__a__before-uninstall.txt`), 'old')
await run('admin', { action: 'uninstall', name: 'Blocker', targets: ['a'] })
const files = readdirSync(history()).sort()
expect(files).toHaveLength(50)
expect(files.some((f) => f.startsWith('2020-01-01T00-00-00.'))).toBe(false)
expect(files.some((f) => f.includes('__a__') && f.startsWith('20') && !f.startsWith('2020-'))).toBe(true)
})
it('does not uninstall when the config cannot be saved', async () => {
a.denyConfig = true
const r = await run('admin', { action: 'uninstall', name: 'Blocker', targets: ['a'] })
expect(r.body.response.results[0].ok).toBe(false)
expect(r.body.response.results[0].message).toMatch(/nothing was uninstalled/)
expect(a.apps.Blocker).toBeDefined()
expect(writes(a)).toHaveLength(0)
})
it('keeps an odd app name inside the data folder', async () => {
a.apps['../../evil'] = { version: '1', config: 'x' }
await run('admin', { action: 'uninstall', name: '../../evil', targets: ['a'] })
expect(existsSync(path.join(dataDir, '..', 'evil'))).toBe(false)
const dirs = readdirSync(path.join(dataDir, 'apps'))
expect(dirs.every((d) => d !== '.' && d !== '..' && !/[\/]/.test(d) && !d.includes('..'))).toBe(true)
})
})
describe('hub/apps/upload', () => {
const bytes = new Uint8Array(70_000).map((_, i) => (i * 7 + 3) % 256)
it('forwards the same zip to every target, byte for byte', async () => {
b.apps = {}
a.apps = {}
const r = await api('apps-admin', 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'Blocker', targets: 'a,b' }, bytes) })
expect(r.body.response.results).toEqual([
{ server: 'a', ok: true, message: 'Installed from the file.' },
{ server: 'b', ok: true, message: 'Installed from the file.' },
])
for (const [f, admin] of [[a, 'admin-a'], [b, 'admin-b']] as const) {
const [w] = writes(f)
expect(w.path).toBe('apps/install')
expect(w.token).toBe(admin)
expect(w.query.get('name')).toBe('Blocker')
const parsed = parseMultipart(w.body, w.contentType)
expect(Buffer.from(parsed.files[0].data).equals(Buffer.from(bytes))).toBe(true)
}
const audit = (await api('admin', 'hub/audit/list', { params: { limit: '10' } })).body.response.entries as Record<string, string>[]
expect(audit.filter((e) => e.action === 'apps.install-file' && e.outcome === 'ok')).toHaveLength(2)
})
it('updates, and reports an app that is not there as that target failure', async () => {
delete b.apps.Blocker
const r = await api('admin', 'hub/apps/upload', { body: zipForm({ action: 'update', name: 'Blocker', targets: 'a,b' }, bytes) })
expect(writes(a)[0].path).toBe('apps/update')
expect(r.body.response.results.map((x: any) => x.ok)).toEqual([true, true])
})
it('refuses a missing file, a bad action and a body above 20 MB', async () => {
const noFile = new FormData()
noFile.append('action', 'install')
noFile.append('name', 'X')
noFile.append('targets', 'a')
expect((await api('admin', 'hub/apps/upload', { body: noFile })).code).toBe(400)
expect((await api('admin', 'hub/apps/upload', { body: zipForm({ action: 'wipe', name: 'X', targets: 'a' }, bytes) })).code).toBe(400)
const big = new Uint8Array(21 * 1024 * 1024)
const r = await api('admin', 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'X', targets: 'a' }, big) })
expect(r.code).toBe(413)
expect(r.body.errorMessage).toMatch(/20 MB/)
expect(writes(a)).toHaveLength(0)
})
it('does not read a big body from someone who is not allowed to upload', async () => {
const r = await api('viewer', 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'X', targets: 'a' }, bytes) })
expect(r.code).toBe(403)
})
})
describe('single-server writes', () => {
it('forwards with the admin token', async () => {
const r = await api('apps-admin', 'apps/downloadAndUpdate', { params: { node: 'a', name: 'Blocker', url: STORE('2.0')[0].url } })
expect(r.body.status).toBe('ok')
const [w] = writes(a)
expect(w).toMatchObject({ path: 'apps/downloadAndUpdate', token: 'admin-a' })
expect(w.query.get('node')).toBeNull()
expect(writes(b)).toHaveLength(0)
expect(a.apps.Blocker.version).toBe('2.0')
})
it('works with a form POST too, and uninstalls with the history', async () => {
const r = await api('admin', 'apps/uninstall', {
body: new URLSearchParams({ node: 'b', name: 'Blocker' }).toString(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
})
expect(r.body.status).toBe('ok')
expect(b.apps.Blocker).toBeUndefined()
expect(readdirSync(path.join(dataDir, 'apps', 'Blocker', 'history'))).toHaveLength(1)
})
it('is refused without a node or with the aggregate', async () => {
for (const params of [{ name: 'Blocker' } as Record<string, string>, { name: 'Blocker', node: 'cluster' }, { name: 'Blocker', node: 'zzz' }]) {
const r = await api('admin', 'apps/uninstall', { params })
expect(r.code).toBe(400)
}
expect(writes(a)).toHaveLength(0)
expect(writes(b)).toHaveLength(0)
})
it('does not forward a download url the store did not offer', async () => {
const r = await api('admin', 'apps/downloadAndInstall', { params: { node: 'a', name: 'Blocker', url: 'https://evil.example/x.zip' } })
expect(r.body.status).toBe('error')
expect(r.body.errorMessage).toMatch(/store listing/)
expect(writes(a)).toHaveLength(0)
})
it('reports a server without an admin token', async () => {
await start({ A_ADMIN_TOKEN: '' })
const r = await api('admin', 'apps/uninstall', { params: { node: 'a', name: 'Blocker' } })
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: no admin token configured' })
expect(a.calls.filter((c) => c.method === 'POST')).toHaveLength(0)
})
it('forwards a multipart install to the node', async () => {
delete a.apps.Blocker
const bytes = new Uint8Array([80, 75, 3, 4, 13, 10, 45, 45, 0, 255])
const r = await api('admin', 'apps/install', { params: { node: 'a', name: 'Blocker' }, body: zipForm({}, bytes) })
expect(r.body.status).toBe('ok')
const [w] = writes(a)
expect(w.token).toBe('admin-a')
expect(Buffer.from(parseMultipart(w.body, w.contentType).files[0].data).equals(Buffer.from(bytes))).toBe(true)
})
it('needs a node for a config read or write', async () => {
for (const p of ['apps/config/get', 'apps/config/set']) {
expect((await api('admin', p, { params: { name: 'Blocker' } })).code).toBe(400)
}
})
it('lists the store with the servers each app is installed on', async () => {
delete b.apps.Blocker
const r = await api('viewer', 'apps/listStoreApps')
expect(r.body.response.storeApps[0]).toMatchObject({ name: 'Blocker', installedOn: ['a'] })
})
})