Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
514 lines
24 KiB
TypeScript
514 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 name = url.searchParams.get('name') ?? ''
|
|
f.calls.push({ path: apiPath, token, method: req.method ?? '', query: url.searchParams, body: Buffer.concat(chunks), contentType: String(req.headers['content-type'] ?? '') })
|
|
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 === url.searchParams.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('keeps config get and set refused for now', async () => {
|
|
for (const p of ['apps/config/get', 'apps/config/set']) {
|
|
expect((await api('admin', p, { params: { node: 'a', name: 'Blocker' } })).code).toBe(403)
|
|
}
|
|
})
|
|
|
|
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'] })
|
|
})
|
|
})
|