import { describe, it, expect } from 'vitest' import { buildApp } from '../src/app.js' import { CpClientError } from '../src/cp-client.js' import { HostIdSchema } from '../src/routes/api-routes.js' import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader } from './helpers.js' const AUTH = { cookie: authCookieHeader() } describe('session gating', () => { it('rejects every proxy route without a valid session cookie (401)', async () => { const app = await buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null }) for (const r of [ { method: 'GET' as const, url: '/api/hosts' }, { method: 'POST' as const, url: '/api/pairing-codes' }, { method: 'DELETE' as const, url: '/api/hosts/abc' }, ]) { const res = await app.inject(r) expect(res.statusCode).toBe(401) } await app.close() }) }) describe('GET /api/hosts', () => { it('mints a token and returns the CP host list for the operator account', async () => { const cpClient = fakeCpClient({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }], }) const minter = fakeMinter('MINTED') const app = await buildApp({ config: makeConfig({ operatorAccountId: 'acct-XYZ' }), cpClient, minter, staticRoot: null }) const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH }) expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }] }) expect(minter.calls()).toBe(1) expect(cpClient.calls[0]).toMatchObject({ method: 'listHosts', accountIdOrHostId: 'acct-XYZ', token: 'MINTED' }) await app.close() }) it('maps an upstream CP failure to 502', async () => { const cpClient = fakeCpClient({ throwErr: new CpClientError(403, 'denied') }) const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null }) const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH }) expect(res.statusCode).toBe(502) expect(res.json()).toEqual({ error: 'upstream_error' }) await app.close() }) }) describe('POST /api/pairing-codes', () => { it('returns the code, pair command, and a QR data URL', async () => { const cpClient = fakeCpClient({ pairing: { code: 'ABCD-EFGH', expiresAt: '2026-03-01T00:00:00.000Z' } }) const app = await buildApp({ config: makeConfig({ tunnelZone: 'z.test' }), cpClient, minter: fakeMinter(), staticRoot: null }) const res = await app.inject({ method: 'POST', url: '/api/pairing-codes', headers: AUTH }) expect(res.statusCode).toBe(201) const body = res.json() expect(body.code).toBe('ABCD-EFGH') expect(body.expiresAt).toBe('2026-03-01T00:00:00.000Z') expect(body.pairCommand).toBe('web-terminal-agent pair ABCD-EFGH --install --zone z.test') expect(String(body.qrDataUrl).startsWith('data:image/png;base64,')).toBe(true) await app.close() }) }) describe('DELETE /api/hosts/:hostId', () => { it('revokes the host and returns 204', async () => { const cpClient = fakeCpClient() const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter('T'), staticRoot: null }) const res = await app.inject({ method: 'DELETE', url: '/api/hosts/host-42', headers: AUTH }) expect(res.statusCode).toBe(204) expect(cpClient.calls[0]).toMatchObject({ method: 'deleteHost', accountIdOrHostId: 'host-42', token: 'T' }) await app.close() }) it('rejects an invalid host id with 400', async () => { const cpClient = fakeCpClient() const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null }) const res = await app.inject({ method: 'DELETE', url: '/api/hosts/' + encodeURIComponent('bad id!'), headers: AUTH }) expect(res.statusCode).toBe(400) expect(cpClient.calls).toHaveLength(0) await app.close() }) }) describe('HostIdSchema dot-segment rejection', () => { // The HTTP router already normalizes literal `.`/`..` path segments, but the outbound CP admin URL is // built as `${cpUrl}/hosts/${hostId}` — so the schema itself must refuse dot-segments (defense-in-depth). it('rejects a bare "." and ".."', () => { expect(HostIdSchema.safeParse('.').success).toBe(false) expect(HostIdSchema.safeParse('..').success).toBe(false) }) it('accepts a UUID host id, a hyphenated id, and "..." (not a dot-segment)', () => { expect(HostIdSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').success).toBe(true) expect(HostIdSchema.safeParse('host-42').success).toBe(true) expect(HostIdSchema.safeParse('...').success).toBe(true) }) })