/** * Tests for the pure helpers exported alongside the public_web * capability factory: client-config rendering and request validation * for the higher-level operations introduced in HOOK_API_V2 Phase 4 * (D4). * * The factory itself opens SSH connections and shells out to tar, so * it's not unit-testable in isolation. These helpers are the seam * where the interesting logic lives. */ import { describe, expect, test } from 'bun:test'; import { type PublishStaticSiteRequest, type RegisterReverseProxyRequest, generateClientConfigJs, validatePublishStaticSiteRequest, validateRegisterReverseProxyRequest, } from '@celilo/capabilities'; describe('generateClientConfigJs', () => { test('renders an empty object as a literal {}', () => { const out = generateClientConfigJs({}); expect(out).toContain('window.__MODULE_CONFIG__ = {};'); }); test('emits the do-not-edit banner', () => { const out = generateClientConfigJs({}); expect(out.split('\n')[0]).toContain('Generated by Celilo'); expect(out.split('\n')[0]).toContain('do not edit'); }); test('renders a single string field', () => { const out = generateClientConfigJs({ CLIENT_ID: 'lunacycle-web' }); expect(out).toContain('"CLIENT_ID":"lunacycle-web"'); }); test('renders multiple fields in one object', () => { const out = generateClientConfigJs({ AUTHENTIK_URL: 'https://auth.example.com', CLIENT_ID: 'lunacycle-web', }); expect(out).toContain('"AUTHENTIK_URL":"https://auth.example.com"'); expect(out).toContain('"CLIENT_ID":"lunacycle-web"'); }); test('round-trips numbers as JSON numbers (not strings)', () => { const out = generateClientConfigJs({ TIMEOUT_MS: 5000, RETRIES: 3 }); expect(out).toContain('"TIMEOUT_MS":5000'); expect(out).toContain('"RETRIES":3'); expect(out).not.toContain('"5000"'); }); test('round-trips booleans as JSON booleans', () => { const out = generateClientConfigJs({ DEBUG: true, PROD: false }); expect(out).toContain('"DEBUG":true'); expect(out).toContain('"PROD":false'); }); test('escapes double quotes inside string values', () => { const out = generateClientConfigJs({ MOTD: 'hello "world"' }); expect(out).toContain('"MOTD":"hello \\"world\\""'); }); test('escapes backslashes inside string values', () => { const out = generateClientConfigJs({ PATH: 'C:\\Users\\test' }); // JSON escapes each backslash, so the output has 2x doubled backslashes. expect(out).toContain('"PATH":"C:\\\\Users\\\\test"'); }); test('escapes newlines and other control characters', () => { const out = generateClientConfigJs({ MULTI: 'a\nb\tc' }); expect(out).toContain('"MULTI":"a\\nb\\tc"'); }); test('output is parseable as JavaScript (banner + assignment)', () => { const out = generateClientConfigJs({ FOO: 'bar', N: 42 }); // The trailing newline keeps the file POSIX-clean. expect(out.endsWith('\n')).toBe(true); // Two non-empty lines: banner + assignment. const lines = out.trim().split('\n'); expect(lines).toHaveLength(2); expect(lines[1]).toMatch(/^window\.__MODULE_CONFIG__ = \{.*\};$/); }); }); function basePublishRequest( overrides: Partial = {}, ): PublishStaticSiteRequest { return { path: '/lunacycle', ...overrides, }; } describe('validatePublishStaticSiteRequest', () => { test('accepts a complete valid request', () => { const result = validatePublishStaticSiteRequest(basePublishRequest()); expect(result.valid).toBe(true); expect(result.errors).toEqual([]); }); test('accepts a request with optional clientConfig and hostname', () => { const result = validatePublishStaticSiteRequest( basePublishRequest({ clientConfig: { FOO: 'bar' }, hostname: 'app.iamtheinternet.org', }), ); expect(result.valid).toBe(true); }); test('rejects empty path', () => { const result = validatePublishStaticSiteRequest(basePublishRequest({ path: '' })); expect(result.valid).toBe(false); expect(result.errors).toContain('Path is required'); }); test('rejects path that does not start with /', () => { const result = validatePublishStaticSiteRequest(basePublishRequest({ path: 'lunacycle' })); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes('must start with /'))).toBe(true); }); test('rejects path with trailing slash', () => { const result = validatePublishStaticSiteRequest(basePublishRequest({ path: '/lunacycle/' })); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes('trailing slash'))).toBe(true); }); test('accepts root path "/"', () => { const result = validatePublishStaticSiteRequest(basePublishRequest({ path: '/' })); expect(result.valid).toBe(true); }); test('rejects a stale sourceDir, and says what to do instead', () => { // Replaces 'rejects empty sourceDir'. The field is gone (D10 amendment), so // the failure mode inverted: supplying one is now the error, and the // message has to carry the directory move because that is not guessable // from "unknown key". const result = validatePublishStaticSiteRequest({ path: '/lunacycle', sourceDir: '/tmp/site', } as unknown as PublishStaticSiteRequest); expect(result.valid).toBe(false); expect(result.errors.join(' ')).toContain('site/dist'); }); test('reports multiple errors at once', () => { const result = validatePublishStaticSiteRequest({ path: '', sourceDir: '/tmp/site', } as unknown as PublishStaticSiteRequest); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThanOrEqual(2); }); }); function baseProxyRequest( overrides: Partial = {}, ): RegisterReverseProxyRequest { return { path: '/lunacycle/api', targetHost: '10.0.20.42', targetPort: 8080, ...overrides, }; } describe('validateRegisterReverseProxyRequest', () => { test('accepts a complete valid request', () => { const result = validateRegisterReverseProxyRequest(baseProxyRequest()); expect(result.valid).toBe(true); }); test('accepts websocket flag', () => { const result = validateRegisterReverseProxyRequest(baseProxyRequest({ websocket: true })); expect(result.valid).toBe(true); }); test('rejects missing targetHost', () => { const result = validateRegisterReverseProxyRequest(baseProxyRequest({ targetHost: '' })); expect(result.valid).toBe(false); expect(result.errors).toContain('targetHost is required'); }); test('rejects port 0', () => { const result = validateRegisterReverseProxyRequest(baseProxyRequest({ targetPort: 0 })); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes('between 1 and 65535'))).toBe(true); }); test('rejects port above 65535', () => { const result = validateRegisterReverseProxyRequest(baseProxyRequest({ targetPort: 70000 })); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes('between 1 and 65535'))).toBe(true); }); test('rejects missing path', () => { const result = validateRegisterReverseProxyRequest(baseProxyRequest({ path: '' })); expect(result.valid).toBe(false); expect(result.errors).toContain('Path is required'); }); });