Files
web-terminal/control-panel/test/cp-client.test.ts
Yaojia Wang 675de771c7 feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time,
signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy
that mints a fresh 60s manage capability token per call to the control-plane admin
API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security
headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests
pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
2026-07-19 19:47:51 +02:00

95 lines
4.2 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { createCpClient, CpClientError } from '../src/cp-client.js'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}
describe('createCpClient.listHosts', () => {
it('maps the CP host records to a curated view and strips agentPubkey/enrollFpr', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return jsonResponse([
{
hostId: 'h1',
accountId: 'acct-1',
subdomain: 'alpha',
agentPubkey: 'BASE64PUBKEY',
enrollFpr: 'fpr',
status: 'online',
lastSeen: '2026-01-02T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
},
])
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const hosts = await client.listHosts('acct-1', 'TOKEN123')
expect(captured.url).toBe('http://127.0.0.1:8080/accounts/acct-1/hosts')
expect((captured.init?.headers as Record<string, string>).authorization).toBe('Bearer TOKEN123')
expect(hosts).toHaveLength(1)
expect(hosts[0]).toMatchObject({ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: '2026-01-02T00:00:00.000Z' })
expect(hosts[0]).not.toHaveProperty('agentPubkey')
expect(hosts[0]).not.toHaveProperty('enrollFpr')
})
it('throws CpClientError carrying the upstream status on non-2xx', async () => {
const fetchFn = (async () => new Response('nope', { status: 403 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ name: 'CpClientError', status: 403 })
})
it('maps a network failure to a 502 CpClientError', async () => {
const fetchFn = (async () => {
throw new Error('ECONNREFUSED')
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ status: 502 })
})
})
describe('createCpClient.createPairingCode', () => {
it('POSTs and maps { code, expiresAt }', async () => {
const captured: { init?: RequestInit | undefined } = {}
const fetchFn = (async (_url: string | URL | Request, init?: RequestInit) => {
captured.init = init
return jsonResponse({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' }, 201)
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const issued = await client.createPairingCode('acct-1', 'TOK')
expect(captured.init?.method).toBe('POST')
expect(issued).toEqual({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' })
})
it('rejects a malformed CP pairing response', async () => {
const fetchFn = (async () => jsonResponse({ nope: true }, 201)) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.createPairingCode('acct-1', 't')).rejects.toBeInstanceOf(Error)
})
})
describe('createCpClient.deleteHost', () => {
it('DELETEs and resolves on 204', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return new Response(null, { status: 204 })
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await client.deleteHost('host-42', 'TOK')
expect(captured.url).toBe('http://127.0.0.1:8080/hosts/host-42')
expect(captured.init?.method).toBe('DELETE')
})
it('throws CpClientError on a non-2xx delete', async () => {
const fetchFn = (async () => new Response('no', { status: 404 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.deleteHost('h', 't')).rejects.toMatchObject({ status: 404 })
})
})