Files
web-terminal/control-panel/test/api-routes.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

100 lines
4.7 KiB
TypeScript

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)
})
})