/** * Integration test for `publishStaticSite`'s clientConfig injection. * * The factory itself opens SSH connections and shells out to tar — we * stub `child_process.execSync` so the test exercises only the parts * that matter for D4: config.js generation, file write into sourceDir, * and the composition order (register_route → write file → upload). * * Why `spyOn` instead of `mock.module`: bun:test's `mock.module()` is * a runtime-global replacement that persists across every test file in * the suite. Mocking `node:child_process` that way breaks unrelated * tests (e.g. CommandTreeParser, deriveSecret) because their own * `execSync` calls hit our stub. `spyOn` is auto-restored after each * test and stays scoped to this file. */ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import * as childProcess from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createPublicWeb } from '@celilo/capabilities'; import type { HookLogger, RouteOps, WebRoute } from '@celilo/capabilities'; import { createCapturingLogger } from '../hooks/logger'; let execSyncSpy: ReturnType; const noopLogger: HookLogger = { info() {}, warn() {}, error() {}, success() {}, }; function makeRouteOps(): { ops: RouteOps; routes: WebRoute[] } { const routes: WebRoute[] = []; let nextId = 1; const ops: RouteOps = { getRoutes(moduleId) { return routes.filter((r) => r.moduleId === moduleId); }, getAllRoutes() { return [...routes]; }, upsertRoute(route) { const existing = routes.findIndex( (r) => r.path === route.path && r.moduleId === route.moduleId, ); const now = new Date(); const stored: WebRoute = { id: existing >= 0 ? routes[existing].id : nextId++, slug: route.slug, moduleId: route.moduleId, type: route.type, path: route.path, hostname: route.hostname, targetHost: route.targetHost ?? null, targetPort: route.targetPort ?? null, websocket: route.websocket ?? false, contentHash: route.contentHash ?? null, createdAt: existing >= 0 ? routes[existing].createdAt : now, updatedAt: now, }; if (existing >= 0) { routes[existing] = stored; } else { routes.push(stored); } }, deleteRoute(moduleId, path) { const i = routes.findIndex((r) => r.moduleId === moduleId && r.path === path); if (i >= 0) routes.splice(i, 1); }, deleteRoutesBySlug(slug) { for (let i = routes.length - 1; i >= 0; i--) { if (routes[i].slug === slug) routes.splice(i, 1); } }, deleteRoutesByModule(moduleId) { for (let i = routes.length - 1; i >= 0; i--) { if (routes[i].moduleId === moduleId) routes.splice(i, 1); } }, }; return { ops, routes }; } describe('publishStaticSite — clientConfig injection', () => { let sourceDir: string; beforeEach(() => { // Stub execSync to a no-op for the duration of this test only. // spyOn auto-restores when the test finishes, so other test files // in the same suite get the real implementation. execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from('')); sourceDir = mkdtempSync(join(tmpdir(), 'publish-static-site-')); // Drop a fake build artifact so the upload "has something to do". writeFileSync(join(sourceDir, 'index.html'), '', 'utf-8'); }); afterEach(() => { execSyncSpy.mockRestore(); rmSync(sourceDir, { recursive: true, force: true }); }); test('writes config.js into sourceDir before upload when clientConfig is provided', async () => { const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); const result = await cap.publishStaticSite({ path: '/lunacycle', clientConfig: { AUTHENTIK_URL: 'https://auth.example.com/application/o', CLIENT_ID: 'lunacycle-web', REDIRECT_URI: 'https://www.example.com/lunacycle/auth/callback', }, }); expect(result.success).toBe(true); expect(result.path).toBe('/lunacycle'); const configPath = join(sourceDir, 'config.js'); expect(existsSync(configPath)).toBe(true); const configContents = readFileSync(configPath, 'utf-8'); expect(configContents).toContain('window.__MODULE_CONFIG__ ='); expect(configContents).toContain('"AUTHENTIK_URL":"https://auth.example.com/application/o"'); expect(configContents).toContain('"CLIENT_ID":"lunacycle-web"'); expect(configContents).toContain( '"REDIRECT_URI":"https://www.example.com/lunacycle/auth/callback"', ); expect(configContents.startsWith('// Generated by Celilo')).toBe(true); }); test('does not write config.js when clientConfig is omitted', async () => { const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await cap.publishStaticSite({ path: '/lunacycle', // no clientConfig }); expect(existsSync(join(sourceDir, 'config.js'))).toBe(false); }); test('registers the route as static and records moduleId/path', async () => { const { ops, routes } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await cap.publishStaticSite({ path: '/lunacycle', }); const stored = routes.find((r) => r.path === '/lunacycle'); expect(stored).toBeDefined(); expect(stored?.type).toBe('static'); expect(stored?.moduleId).toBe('lunacycle'); expect(stored?.slug).toBe('lunacycle'); }); test('fails fast, and names the path to ship, when the module has no web root', async () => { // The publish path's one unrecoverable input. It used to arrive on the // request as `sourceDir`; core now resolves it (D10 amendment), so the // missing-directory case moves onto the capability's construction. // // It must throw rather than upload nothing: a zero-file upload succeeds, // writes a content hash over an empty release, and serves a blank page. const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: '/nonexistent/path/that/should/not/exist', moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); const attempt = cap.publishStaticSite({ path: '/lunacycle', clientConfig: { FOO: 'bar' }, }); await expect(attempt).rejects.toThrow('no built site at'); // The operator's next move is a directory move, so the error has to name it. await expect(attempt).rejects.toThrow('site/dist'); }); test('fails validation before touching the filesystem', async () => { const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await expect( cap.publishStaticSite({ path: '', // bad — caught by validator before any side effects }), ).rejects.toThrow('Invalid publishStaticSite request'); // The doomed call must not have written config.js or registered a route. expect(existsSync(join(sourceDir, 'config.js'))).toBe(false); }); }); describe('the state web overlay (celilo#1265)', () => { beforeEach(() => { execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from('')); }); afterEach(() => { execSyncSpy.mockRestore(); }); // A jailed hook cannot write into its own module tree, so generated site // files land in `/state/site`. The upload's content hash must see // them, or the release directory never refreshes when the overlay changes. test('the upload hash and file count include the state overlay', async () => { const sourceDir = mkdtempSync(join(tmpdir(), 'publish-overlay-')); const overlayDir = join(mkdtempSync(join(tmpdir(), 'publish-overlay-state-'))); writeFileSync(join(sourceDir, 'index.html'), '', 'utf-8'); try { const { ops, routes } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, stateWebRoot: overlayDir, moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); const hashOf = () => routes.find((r) => r.moduleId === 'lunacycle' && r.path === '/lunacycle')?.contentHash; // No overlay yet: one file, and the hash covers only the web root. const before = await cap.publishStaticSite({ path: '/lunacycle' }); const hashBefore = hashOf(); expect(before.filesUploaded).toBe(1); expect(hashBefore).toBeTruthy(); // A generated file in state must move the hash, or a rebuilt host's // converge would re-serve the old release and the file would never land. writeFileSync(join(overlayDir, 'ca.crt'), '---CERT---', 'utf-8'); const after = await cap.publishStaticSite({ path: '/lunacycle' }); expect(after.filesUploaded).toBe(2); expect(hashOf()).toBeTruthy(); expect(hashOf()).not.toBe(hashBefore); } finally { rmSync(sourceDir, { recursive: true, force: true }); rmSync(overlayDir, { recursive: true, force: true }); } }); }); describe('registerReverseProxy', () => { beforeEach(() => { execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from('')); }); afterEach(() => { execSyncSpy.mockRestore(); }); test('registers a reverse_proxy route and returns the path', async () => { const { ops, routes } = makeRouteOps(); const cap = createPublicWeb({ moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); const result = await cap.registerReverseProxy({ path: '/lunacycle/api', targetHost: '10.0.20.42', targetPort: 8080, websocket: true, }); expect(result.success).toBe(true); expect(result.path).toBe('/lunacycle/api'); const stored = routes.find((r) => r.path === '/lunacycle/api'); expect(stored).toBeDefined(); expect(stored?.type).toBe('reverse_proxy'); expect(stored?.targetHost).toBe('10.0.20.42'); expect(stored?.targetPort).toBe(8080); expect(stored?.websocket).toBe(true); }); test('rejects invalid request before touching routeOps', async () => { const { ops, routes } = makeRouteOps(); const cap = createPublicWeb({ moduleId: 'lunacycle', logger: noopLogger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await expect( cap.registerReverseProxy({ path: '/lunacycle/api', targetHost: '', targetPort: 8080, }), ).rejects.toThrow('Invalid registerReverseProxy request'); expect(routes).toHaveLength(0); }); }); describe('auto-logging — end-to-end through createPublicWeb', () => { let sourceDir: string; beforeEach(() => { execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from('')); sourceDir = mkdtempSync(join(tmpdir(), 'autolog-publish-')); writeFileSync(join(sourceDir, 'index.html'), '', 'utf-8'); }); afterEach(() => { execSyncSpy.mockRestore(); rmSync(sourceDir, { recursive: true, force: true }); }); test('successful primitive call emits → and ✓ markers via the captured logger', async () => { const { logger, messages } = createCapturingLogger(); const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await cap.register_route({ type: 'reverse_proxy', path: '/lunacycle/api', targetHost: '10.0.20.42', targetPort: 8080, }); const messageTexts = messages.map((m) => m.message); expect(messageTexts).toContain('→ public_web.register_route'); expect(messageTexts).toContain('✓ public_web.register_route'); // The error marker must NOT appear on a successful call. expect(messageTexts.some((m) => m.startsWith('✗'))).toBe(false); }); test('failed primitive call emits ✗ marker and re-throws', async () => { const { logger, messages } = createCapturingLogger(); const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); // Bad path triggers the validator inside register_route, which // throws — the wrapper should catch, log, and re-throw. await expect( cap.register_route({ type: 'static', path: 'no-leading-slash', // invalid }), ).rejects.toThrow('Invalid route'); const errorMessage = messages.find((m) => m.level === 'error'); expect(errorMessage).toBeDefined(); expect(errorMessage?.message).toContain('✗ public_web.register_route'); expect(errorMessage?.message).toContain('Invalid route'); }); test('high-level call composes its primitives and logs each step', async () => { const { logger, messages } = createCapturingLogger(); const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await cap.publishStaticSite({ path: '/lunacycle' }); const messageTexts = messages.map((m) => m.message); // The high-level call itself logs. expect(messageTexts).toContain('→ public_web.publishStaticSite'); expect(messageTexts).toContain('✓ public_web.publishStaticSite'); // It also composes register_route + upload_static_assets internally. // Those run via the unwrapped reference (`capability.` from // inside the factory closure), so they do NOT produce extra arrow // markers — only the outer publishStaticSite call is logged. // This keeps high-level calls a single semantic step in logs. expect(messageTexts.filter((m) => m.startsWith('→'))).toHaveLength(1); expect(messageTexts.filter((m) => m.startsWith('✓'))).toHaveLength(1); }); test('does not log payloads or result values', async () => { const { logger, messages } = createCapturingLogger(); const { ops } = makeRouteOps(); const cap = createPublicWeb({ webRoot: sourceDir, moduleId: 'lunacycle', logger, config: { target_ip: '10.0.10.20/24', hostname: 'www', primary_domain: 'example.com', email: 'admin@example.com', }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['www.example.com'], }); await cap.register_route({ type: 'reverse_proxy', path: '/lunacycle/api', targetHost: '10.0.20.42', targetPort: 8080, }); // None of the request fields should appear in the log lines. for (const m of messages) { expect(m.message).not.toContain('10.0.20.42'); expect(m.message).not.toContain('8080'); expect(m.message).not.toContain('lunacycle/api'); } }); }); describe('register_route — public DNS wiring (D1/M1 #328)', () => { beforeEach(() => { execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from('')); }); afterEach(() => { execSyncSpy.mockRestore(); }); function makeRegistrar(): { registrar: { registerHost: (r: { fqdn: string; ip?: string }) => Promise<{ success: boolean }>; }; calls: Array<{ fqdn: string; ip?: string }>; } { const calls: Array<{ fqdn: string; ip?: string }> = []; return { calls, registrar: { async registerHost(r) { calls.push(r); return { success: true }; }, }, }; } test('registers the public A record for a NEW hostname without supplying an IP', async () => { const { ops } = makeRouteOps(); const { registrar, calls } = makeRegistrar(); const cap = createPublicWeb({ moduleId: 'nexus', logger: noopLogger, config: { target_ip: '10.0.10.20/24' }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub dnsRegistrar: registrar as any, }); await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com', }); // No `ip` — the registrar publishes the source address of the update // request, which is by construction the address the internet must dial. expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]); }); test('skips the default hostname (caddy already registered it at install)', async () => { const { ops } = makeRouteOps(); const { registrar, calls } = makeRegistrar(); const cap = createPublicWeb({ moduleId: 'root', logger: noopLogger, config: { target_ip: '10.0.10.20/24' }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub dnsRegistrar: registrar as any, }); await cap.register_route({ type: 'static', path: '/', hostname: 'www.example.com' }); expect(calls).toHaveLength(0); }); // D2 / ce-phz — reachability wiring is authoritative and LOUD: a new // hostname that can't be wired FAILS the deploy instead of silently // reporting success (the "served but unreachable" anti-pattern). // The regression guard for #464. This case used to THROW "caddy has no known // external IP" — and it threw on every real fleet, because the stored copy it // demanded was never populated: caddy produced a `public_ip` hook output that // its manifest never declared, so the framework discarded it. A correct fleet // with correct public DNS could not register a new hostname. Registration must // not depend on knowing the external IP at all. test('registers a NEW hostname even though no external IP is known anywhere', async () => { const { ops } = makeRouteOps(); const { registrar, calls } = makeRegistrar(); const cap = createPublicWeb({ moduleId: 'nexus', logger: noopLogger, config: { target_ip: '10.0.10.20/24' }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub dnsRegistrar: registrar as any, }); await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }); expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]); }); test('fails loudly when no dns_registrar is available for a NEW hostname', async () => { const { ops } = makeRouteOps(); const cap = createPublicWeb({ moduleId: 'nexus', logger: noopLogger, config: { target_ip: '10.0.10.20/24' }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // dnsRegistrar omitted }); await expect( cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }), ).rejects.toThrow('no dns_registrar capability'); }); test('fails loudly when the registrar reports the public registration failed', async () => { const { ops } = makeRouteOps(); const registrar = { async registerHost() { return { success: false, error: 'DDNS auth rejected' }; }, }; const cap = createPublicWeb({ moduleId: 'nexus', logger: noopLogger, config: { target_ip: '10.0.10.20/24' }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub dnsRegistrar: registrar as any, }); await expect( cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }), ).rejects.toThrow('DDNS auth rejected'); }); test('fails loudly when an internal resolver is present but has no target IP', async () => { const { ops } = makeRouteOps(); const { registrar } = makeRegistrar(); const dnsInternal = { async registerRecord() {}, async deleteRecord() {}, }; const cap = createPublicWeb({ moduleId: 'nexus', logger: noopLogger, // No target_ip and no firewallNatIp → internalDnsIp is falsy. config: {}, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub dnsRegistrar: registrar as any, // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub dnsInternal: dnsInternal as any, }); await expect( cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }), ).rejects.toThrow('no internal DNS target IP'); }); test('wires internal DNS when a resolver is present and reachable', async () => { const { ops } = makeRouteOps(); const { registrar } = makeRegistrar(); const internalCalls: Array<{ host: string; value: string }> = []; const dnsInternal = { async registerRecord(r: { host: string; value: string }) { internalCalls.push({ host: r.host, value: r.value }); }, async deleteRecord() {}, }; const cap = createPublicWeb({ moduleId: 'nexus', logger: noopLogger, config: { target_ip: '10.0.10.20/24' }, secrets: {}, convergeStaticContent: async () => {}, routeOps: ops, hostnames: ['www.example.com'], caddyModuleId: 'caddy', dnsManagedDomains: ['example.com'], // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub dnsRegistrar: registrar as any, firewallNatIp: '192.168.0.253', // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub dnsInternal: dnsInternal as any, }); await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }); expect(internalCalls).toEqual([{ host: 'nexus.example.com', value: '192.168.0.253' }]); }); });