import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { createHash } from 'node:crypto'; import type { IndexEntry } from './client'; import { DEFAULT_REGISTRY, RegistryClient } from './client'; // ── sparseIndexPath ─────────────────────────────────────────────────────────── describe('RegistryClient.sparseIndexPath', () => { const client = new RegistryClient('https://reg.example.com'); test('1-char name → index/1/{name}', () => { expect(client.sparseIndexPath('a')).toBe('https://reg.example.com/index/1/a'); }); test('2-char name → index/2/{name}', () => { expect(client.sparseIndexPath('ab')).toBe('https://reg.example.com/index/2/ab'); }); test('3-char name → index/3/{first2}/{name}', () => { expect(client.sparseIndexPath('abc')).toBe('https://reg.example.com/index/3/ab/abc'); }); test('4-char name → index/{01}/{23}/{name}', () => { expect(client.sparseIndexPath('abcd')).toBe('https://reg.example.com/index/ab/cd/abcd'); }); test('long name → index/{01}/{23}/{name}', () => { expect(client.sparseIndexPath('homebridge')).toBe( 'https://reg.example.com/index/ho/me/homebridge', ); }); test('real module names produce expected paths', () => { expect(client.sparseIndexPath('caddy')).toBe('https://reg.example.com/index/ca/dd/caddy'); expect(client.sparseIndexPath('dns-external')).toBe( 'https://reg.example.com/index/dn/s-/dns-external', ); expect(client.sparseIndexPath('celilo-registry')).toBe( 'https://reg.example.com/index/ce/li/celilo-registry', ); }); }); // ── constructor / baseUrl ───────────────────────────────────────────────────── describe('RegistryClient constructor', () => { const origEnv = process.env.CELILO_REGISTRY_URL; afterEach(() => { if (origEnv === undefined) { delete process.env.CELILO_REGISTRY_URL; } else { process.env.CELILO_REGISTRY_URL = origEnv; } }); test('uses DEFAULT_REGISTRY when no arg and no env var', () => { delete process.env.CELILO_REGISTRY_URL; const client = new RegistryClient(); expect(client.baseUrl).toBe(DEFAULT_REGISTRY); }); test('prefers explicit arg over env var', () => { process.env.CELILO_REGISTRY_URL = 'https://env.example.com'; const client = new RegistryClient('https://arg.example.com'); expect(client.baseUrl).toBe('https://arg.example.com'); }); test('falls back to env var when no arg', () => { process.env.CELILO_REGISTRY_URL = 'https://env.example.com'; const client = new RegistryClient(); expect(client.baseUrl).toBe('https://env.example.com'); }); test('strips trailing slashes', () => { const client = new RegistryClient('https://reg.example.com///'); expect(client.baseUrl).toBe('https://reg.example.com'); }); }); // ── latestVersion ───────────────────────────────────────────────────────────── describe('RegistryClient.latestVersion', () => { const client = new RegistryClient('https://reg.example.com'); function entry(vers: string, yanked = false): IndexEntry { return { name: 'test', vers, deps: [], cksum: '', yanked }; } test('returns undefined for empty entries', () => { expect(client.latestVersion([])).toBeUndefined(); }); test('returns the last non-yanked entry', () => { const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.0+3')]; expect(client.latestVersion(entries)?.vers).toBe('1.0.0+3'); }); test('skips yanked versions at the end', () => { const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.0+3', true)]; expect(client.latestVersion(entries)?.vers).toBe('1.0.0+2'); }); test('returns undefined when all yanked', () => { const entries = [entry('1.0.0+1', true), entry('1.0.0+2', true)]; expect(client.latestVersion(entries)).toBeUndefined(); }); test('does not mutate the original array', () => { const entries = [entry('1.0.0+1'), entry('1.0.0+2')]; client.latestVersion(entries); expect(entries[0].vers).toBe('1.0.0+1'); expect(entries[1].vers).toBe('1.0.0+2'); }); }); // ── getIndex ────────────────────────────────────────────────────────────────── describe('RegistryClient.getIndex', () => { let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = spyOn(globalThis, 'fetch'); }); afterEach(() => { fetchSpy.mockRestore(); }); function makeNdJson(entries: IndexEntry[]): string { return `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`; } test('returns empty array on 404', async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 404 })); const client = new RegistryClient('https://reg.example.com'); const result = await client.getIndex('homebridge'); expect(result).toEqual([]); }); test('throws on non-404 error status', async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 500 })); const client = new RegistryClient('https://reg.example.com'); await expect(client.getIndex('homebridge')).rejects.toThrow('Registry index error: HTTP 500'); }); test('parses NDJSON index correctly', async () => { const entries: IndexEntry[] = [ { name: 'homebridge', vers: '1.4.2+1', deps: [], cksum: 'abc', yanked: false }, { name: 'homebridge', vers: '1.4.2+2', deps: [], cksum: 'def', yanked: false }, ]; fetchSpy.mockResolvedValue(new Response(makeNdJson(entries), { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const result = await client.getIndex('homebridge'); expect(result).toHaveLength(2); expect(result[0].vers).toBe('1.4.2+1'); expect(result[1].vers).toBe('1.4.2+2'); }); test('calls the correct sparse index URL', async () => { fetchSpy.mockResolvedValue(new Response('', { status: 200 })); const client = new RegistryClient('https://reg.example.com'); await client.getIndex('homebridge'); expect(fetchSpy).toHaveBeenCalledWith( 'https://reg.example.com/index/ho/me/homebridge', expect.anything(), ); }); }); // ── publish binary protocol ─────────────────────────────────────────────────── describe('RegistryClient.publish — Cargo binary protocol', () => { let fetchSpy: ReturnType; let tmpFile: string; beforeEach(async () => { fetchSpy = spyOn(globalThis, 'fetch'); tmpFile = `/tmp/test-publish-${Date.now()}.netapp`; await Bun.write(tmpFile, 'fake netapp content'); }); afterEach(async () => { fetchSpy.mockRestore(); if (await Bun.file(tmpFile).exists()) await Bun.write(tmpFile, ''); }); test('constructs correct Cargo binary framing', async () => { fetchSpy.mockResolvedValue( new Response(JSON.stringify({ ok: true, name: 'my-mod', vers: '1.0.0+1' }), { status: 200 }), ); const client = new RegistryClient('https://reg.example.com'); await client.publish({ name: 'my-mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://reg.example.com/api/v1/modules/new'); expect((init.headers as Record).Authorization).toBe('tok'); const body = init.body as Buffer; const metaLen = body.readUInt32LE(0); const metaJson = JSON.parse(body.subarray(4, 4 + metaLen).toString('utf-8')) as { name: string; vers: string; deps: unknown[]; cksum: string; }; expect(metaJson.name).toBe('my-mod'); expect(metaJson.vers).toBe('1.0.0+1'); expect(metaJson.deps).toEqual([]); expect(metaJson.cksum).toMatch(/^sha256:[0-9a-f]{64}$/); const fileLen = body.readUInt32LE(4 + metaLen); const fileBytes = body.subarray(4 + metaLen + 4); expect(fileLen).toBe(fileBytes.length); expect(fileBytes.toString('utf-8')).toBe('fake netapp content'); }); test('throws with registry error detail on non-200', async () => { fetchSpy.mockResolvedValue( new Response(JSON.stringify({ errors: [{ detail: 'token invalid' }] }), { status: 403 }), ); const client = new RegistryClient('https://reg.example.com'); await expect( client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'bad' }), ).rejects.toThrow('token invalid'); }); }); // ── RegistryClient.publish — failure bodies (ce-sxb3, celilo#1370) ─────────── describe('RegistryClient.publish — failure bodies (ce-sxb3)', () => { let tmpFile: string; let server: ReturnType | undefined; beforeEach(async () => { tmpFile = `/tmp/test-publish-ce-sxb3-${Date.now()}.netapp`; await Bun.write(tmpFile, 'fake netapp content'); }); afterEach(async () => { server?.stop(true); if (await Bun.file(tmpFile).exists()) await Bun.write(tmpFile, ''); }); function startStubServer(body: string | null, status: number, contentType?: string): string { server = Bun.serve({ port: 0, fetch: () => new Response(body, { status, ...(contentType ? { headers: { 'Content-Type': contentType } } : {}), }), }); return `http://localhost:${server.port}`; } test('JSON error body reports the refusal detail and the HTTP status', async () => { const client = new RegistryClient( startStubServer( JSON.stringify({ errors: [{ detail: 'version already exists' }] }), 409, 'application/json', ), ); await expect( client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }), ).rejects.toThrow('version already exists (HTTP 409)'); }); test('JSON null body names the HTTP status instead of crashing on err.errors', async () => { const client = new RegistryClient(startStubServer('null', 500, 'application/json')); await expect( client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }), ).rejects.toThrow('HTTP 500'); }); test('non-JSON body names the HTTP status', async () => { const client = new RegistryClient( startStubServer('bad gateway', 502, 'text/html'), ); await expect( client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }), ).rejects.toThrow('HTTP 502'); }); test('empty body names the HTTP status', async () => { const client = new RegistryClient(startStubServer(null, 403)); await expect( client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }), ).rejects.toThrow('HTTP 403'); }); }); // ── module-owner admin endpoints (ce-1ch) ───────────────────────────────────── describe('RegistryClient owner methods', () => { let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = spyOn(globalThis, 'fetch'); }); afterEach(() => { fetchSpy.mockRestore(); }); test('listOwners GETs /owners with the admin token and returns the array', async () => { const owners = [ { moduleName: 'homebridge', ownerSub: 'alice', claimedAt: 'T', sourceGroup: 'celilo-authors', }, ]; fetchSpy.mockResolvedValue(new Response(JSON.stringify({ owners }), { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const result = await client.listOwners('admin-tok'); const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://reg.example.com/api/v1/modules/owners'); expect((init.headers as Record).Authorization).toBe('admin-tok'); expect(result).toEqual(owners); }); test('listOwners throws the server error detail on failure', async () => { fetchSpy.mockResolvedValue( new Response(JSON.stringify({ errors: [{ detail: 'Unauthorized' }] }), { status: 401 }), ); const client = new RegistryClient('https://reg.example.com'); await expect(client.listOwners('nope')).rejects.toThrow('Unauthorized'); }); test('getOwner returns null on 404 (unclaimed)', async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 404 })); const client = new RegistryClient('https://reg.example.com'); expect(await client.getOwner('never-claimed', 'admin-tok')).toBeNull(); }); test('getOwner returns the owner entry on 200', async () => { const owner = { moduleName: 'caddy', ownerSub: 'bob', claimedAt: 'T', sourceGroup: 'celilo-admins', }; fetchSpy.mockResolvedValue(new Response(JSON.stringify({ owner }), { status: 200 })); const client = new RegistryClient('https://reg.example.com'); expect(await client.getOwner('caddy', 'admin-tok')).toEqual(owner); }); test('setOwner POSTs ownerSub and returns the updated entry', async () => { const owner = { moduleName: 'homebridge', ownerSub: 'carol', claimedAt: 'T', sourceGroup: 'admin-reassign', }; fetchSpy.mockResolvedValue(new Response(JSON.stringify({ ok: true, owner }), { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const result = await client.setOwner('homebridge', 'carol', 'admin-tok'); const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://reg.example.com/api/v1/modules/owners/homebridge'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual({ ownerSub: 'carol' }); expect(result).toEqual(owner); }); test('setOwner throws the server error detail on failure', async () => { fetchSpy.mockResolvedValue( new Response(JSON.stringify({ errors: [{ detail: 'ownerSub is required' }] }), { status: 400, }), ); const client = new RegistryClient('https://reg.example.com'); await expect(client.setOwner('homebridge', '', 'admin-tok')).rejects.toThrow( 'ownerSub is required', ); }); }); // ── get() retry ─────────────────────────────────────────────────────────────── // // Every registry read goes through one private `get()`. It used to make exactly // one attempt, so a single transient — the shape the 15s→30s timeout bump was // already chasing — failed a module install outright. Measured 2026-09-04: // `module import iptables` failed twice with "Download failed: The operation // timed out" while the identical call had succeeded minutes earlier. describe('RegistryClient.get retry', () => { let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = spyOn(globalThis, 'fetch'); }); afterEach(() => { fetchSpy.mockRestore(); }); test('a transient network failure is retried and the download succeeds', async () => { const payload = new Uint8Array([1, 2, 3, 4]); fetchSpy .mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError')) .mockResolvedValueOnce(new Response(payload, { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const data = await client.download('iptables', '3.1.4'); expect(new Uint8Array(data)).toEqual(payload); expect(fetchSpy).toHaveBeenCalledTimes(2); }); test('a 5xx is retried', async () => { fetchSpy .mockResolvedValueOnce(new Response(null, { status: 503 })) .mockResolvedValueOnce(new Response(new Uint8Array([9]), { status: 200 })); const client = new RegistryClient('https://reg.example.com'); await client.download('iptables', '3.1.4'); expect(fetchSpy).toHaveBeenCalledTimes(2); }); test('a 404 is NOT retried — it is an answer, not a transient', async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 404 })); const client = new RegistryClient('https://reg.example.com'); await expect(client.download('nope', '1.0.0')).rejects.toThrow('HTTP 404'); expect(fetchSpy).toHaveBeenCalledTimes(1); }); test('gives up after a bounded number of attempts', async () => { fetchSpy.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')); const client = new RegistryClient('https://reg.example.com'); const error = await client.download('iptables', '3.1.4').then( () => undefined, (e: unknown) => e, ); expect(error).toBeInstanceOf(Error); // The exhaustion report is the event, not the last attempt (celilo#1264): // the bare lastError read as an unretried first attempt, because that is // exactly what the same string meant before the retry existed. expect((error as Error).message).toMatch(/failed after 3 attempts over \d+(\.\d+)?s/); expect((error as Error).message).toContain('The operation timed out.'); expect((error as { cause?: unknown }).cause).toBeInstanceOf(DOMException); expect(fetchSpy).toHaveBeenCalledTimes(3); }); }); // ── download integrity ──────────────────────────────────────────────────────── // // `cksum` is computed at publish time, shipped in every index entry, and was // never checked on the consuming side. A short download was therefore accepted // and blew up later in gunzip as "zlib: unexpected end of file", which blames // the package rather than the transfer. Measured 2026-09-04 in the e2e rig. // Retrying cannot help a fault it cannot detect, so verification is what makes // the retry meaningful. describe('RegistryClient.download integrity', () => { let fetchSpy: ReturnType; const body = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); // sha256 of the 8 bytes above, in the `sha256:` shape publish() writes. const goodCksum = `sha256:${createHash('sha256').update(body).digest('hex')}`; beforeEach(() => { fetchSpy = spyOn(globalThis, 'fetch'); }); afterEach(() => { fetchSpy.mockRestore(); }); test('a truncated payload is rejected and retried, then succeeds', async () => { fetchSpy .mockResolvedValueOnce(new Response(body.slice(0, 3), { status: 200 })) // short .mockResolvedValueOnce(new Response(body, { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const data = await client.download('iptables', '3.1.4', goodCksum); expect(new Uint8Array(data)).toEqual(body); expect(fetchSpy).toHaveBeenCalledTimes(2); }); test('a payload that never verifies fails with an error naming the integrity check', async () => { // A fresh Response per call: one shared instance would be consumed by the // first attempt and the retry would report "Body already used", hiding the // integrity failure this test exists to assert. fetchSpy.mockImplementation(() => new Response(body.slice(0, 3), { status: 200 })); const client = new RegistryClient('https://reg.example.com'); await expect(client.download('iptables', '3.1.4', goodCksum)).rejects.toThrow( /integrity check/i, ); expect(fetchSpy).toHaveBeenCalledTimes(3); }); test('a correct payload verifies on the first attempt', async () => { fetchSpy.mockResolvedValue(new Response(body, { status: 200 })); const client = new RegistryClient('https://reg.example.com'); await client.download('iptables', '3.1.4', goodCksum); expect(fetchSpy).toHaveBeenCalledTimes(1); }); test('no expected cksum still downloads — callers may not have one', async () => { fetchSpy.mockResolvedValue(new Response(body, { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const data = await client.download('iptables', '3.1.4'); expect(new Uint8Array(data)).toEqual(body); }); test('a non-digest cksum sentinel skips verification instead of failing', async () => { // The registry's bootstrap path publishes `cksum: 'bootstrap'` // (packages/registry-server/src/bootstrap.ts:114) because those modules are // packaged on demand and have no stable digest. Treating a sentinel as a // digest rejects a perfectly good 2.7MB package on every import. fetchSpy.mockImplementation(() => new Response(body, { status: 200 })); const client = new RegistryClient('https://reg.example.com'); const data = await client.download('iptables', '3.1.4', 'bootstrap'); expect(new Uint8Array(data)).toEqual(body); expect(fetchSpy).toHaveBeenCalledTimes(1); }); });