// @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 store: { name: string; version: string; url: string; description: string; size: string }[] calls: Call[] failWrites: boolean denyConfig: boolean } function fake(): Promise { 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 = {} 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 = {}): Promise { hub?.close() hub = createHub( parseConfig({ HUB_SERVERS: `a=${a.url},b=${b.url}`, A_TOKEN: 'read-a', B_TOKEN: 'read-b', A_ADMIN_TOKEN: 'admin-a', B_ADMIN_TOKEN: 'admin-b', HUB_DATA: dataDir, HUB_STATIC: staticDir, HUB_CACHE_SECONDS: '0', HUB_TIMEOUT_MS: '2000', ...extra, }), ) await new Promise((resolve) => hub!.listen(0, '127.0.0.1', resolve)) base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}` for (const role of ['viewer', '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; method?: string; json?: unknown; body?: string | FormData; headers?: Record } = {}) { const query = new URLSearchParams(init.params ?? {}) const headers: Record = { 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 } } const run = (role: string, json: Record) => api(role, 'hub/apps/run', { json }) function zipForm(extra: Record, 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[] 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[] 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[] 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, { 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'] }) }) })