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 <[email protected]>
518 lines
24 KiB
TypeScript
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'] })
|
|
})
|
|
})
|