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.
95 lines
4.2 KiB
TypeScript
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 })
|
|
})
|
|
})
|