/** * Tests for `celilo registry owner list/show/set` (ce-1ch, SECURE_MODULE_PUBLISH.md D-C/D-E). * The admin token is resolved from the local publish_tokens list; registry * calls are stubbed so these stay hermetic. */ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb, getDb } from '../../db/client'; import { modules } from '../../db/schema'; import { resetTestDbPath } from '../../test-utils/db-path'; import { handleRegistryOwnerList, handleRegistryOwnerSet, handleRegistryOwnerShow, } from './registry-owner'; import { handleRegistryTokenAdd } from './registry-token'; const NO_FLAGS: Record = {}; describe('registry owner commands', () => { let dataDir: string; let fetchSpy: ReturnType; beforeEach(async () => { dataDir = await mkdtemp(join(tmpdir(), 'celilo-registry-owner-')); process.env.CELILO_DATA_DIR = dataDir; process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db'); getDb() .insert(modules) .values({ id: 'celilo-registry', name: 'Celilo Registry', version: '1.0.0', sourcePath: '/test', manifestData: {}, }) .run(); fetchSpy = spyOn(globalThis, 'fetch'); }); afterEach(async () => { fetchSpy.mockRestore(); closeDb(); delete process.env.CELILO_DATA_DIR; resetTestDbPath(); if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true }); }); test('list errors when no admin token is configured', async () => { const result = await handleRegistryOwnerList(NO_FLAGS); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('admin publish token'); }); test('list resolves the admin token and renders the owner table', async () => { // Seed a scoped token first (ignored) then a bare admin token (used). await handleRegistryTokenAdd(['cpt_scoped homebridge']); await handleRegistryTokenAdd(['admin-tok']); fetchSpy.mockResolvedValue( new Response( JSON.stringify({ owners: [ { moduleName: 'homebridge', ownerSub: 'alice', claimedAt: 'T', sourceGroup: 'celilo-authors', }, ], }), { status: 200 }, ), ); const result = await handleRegistryOwnerList(NO_FLAGS); expect(result.success).toBe(true); const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; // The bare admin token is used, not the scoped one. expect((init.headers as Record).Authorization).toBe('admin-tok'); if (result.success) expect(result.message).toContain('homebridge'); }); test('list reports an empty table cleanly', async () => { await handleRegistryTokenAdd(['admin-tok']); fetchSpy.mockResolvedValue(new Response(JSON.stringify({ owners: [] }), { status: 200 })); const result = await handleRegistryOwnerList(NO_FLAGS); expect(result.success).toBe(true); if (result.success) expect(result.message).toContain('No module ownerships'); }); test('show requires a module argument', async () => { const result = await handleRegistryOwnerShow([], NO_FLAGS); expect(result.success).toBe(false); }); test('show reports an unclaimed name', async () => { await handleRegistryTokenAdd(['admin-tok']); fetchSpy.mockResolvedValue(new Response(null, { status: 404 })); const result = await handleRegistryOwnerShow(['never-claimed'], NO_FLAGS); expect(result.success).toBe(true); if (result.success) expect(result.message).toContain('unclaimed'); }); test('show renders the owner of a claimed name', async () => { await handleRegistryTokenAdd(['admin-tok']); fetchSpy.mockResolvedValue( new Response( JSON.stringify({ owner: { moduleName: 'caddy', ownerSub: 'bob', claimedAt: 'T', sourceGroup: 'celilo-admins', }, }), { status: 200 }, ), ); const result = await handleRegistryOwnerShow(['caddy'], NO_FLAGS); expect(result.success).toBe(true); if (result.success) expect(result.message).toContain('bob'); }); test('set requires both module and owner-sub', async () => { expect((await handleRegistryOwnerSet(['homebridge'], NO_FLAGS)).success).toBe(false); expect((await handleRegistryOwnerSet([], NO_FLAGS)).success).toBe(false); }); test('set POSTs the reassignment with the admin token', async () => { await handleRegistryTokenAdd(['admin-tok']); fetchSpy.mockResolvedValue( new Response( JSON.stringify({ ok: true, owner: { moduleName: 'homebridge', ownerSub: 'carol', claimedAt: 'T', sourceGroup: 'admin-reassign', }, }), { status: 200 }, ), ); const result = await handleRegistryOwnerSet(['homebridge', 'carol'], NO_FLAGS); expect(result.success).toBe(true); const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(url).toContain('/api/v1/modules/owners/homebridge'); expect(init.method).toBe('POST'); expect((init.headers as Record).Authorization).toBe('admin-tok'); if (result.success) expect(result.message).toContain('carol'); }); test('surfaces a registry error detail', async () => { await handleRegistryTokenAdd(['admin-tok']); fetchSpy.mockResolvedValue( new Response(JSON.stringify({ errors: [{ detail: 'Unauthorized' }] }), { status: 401 }), ); const result = await handleRegistryOwnerList(NO_FLAGS); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('Unauthorized'); }); });