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

75 lines
2.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
loadConfig,
DEFAULT_CP_URL,
DEFAULT_TUNNEL_ZONE,
DEFAULT_PANEL_BIND_PORT,
DEFAULT_CAPABILITY_SIGN_KEY_PATH,
} from '../src/config.js'
const base = {
SESSION_SECRET: 'a-sufficiently-long-secret-value',
BASE_DOMAIN: 'terminal.yaojia.wang',
OPERATOR_ACCOUNT_ID: 'acct-1',
}
describe('loadConfig', () => {
it('applies defaults for optional fields', () => {
const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv)
expect(cfg.cpUrl).toBe(DEFAULT_CP_URL)
expect(cfg.tunnelZone).toBe(DEFAULT_TUNNEL_ZONE)
expect(cfg.panelBindPort).toBe(DEFAULT_PANEL_BIND_PORT)
expect(cfg.capabilitySignKeyPath).toBe(DEFAULT_CAPABILITY_SIGN_KEY_PATH)
expect(cfg.panelPassword).toBeUndefined()
})
it('reads all provided values and strips trailing slash from CP_URL', () => {
const cfg = loadConfig({
...base,
PANEL_PASSWORD: 'pw',
CP_URL: 'http://127.0.0.1:9000/',
TUNNEL_ZONE: 'z.example',
PANEL_BIND_PORT: '9999',
} as NodeJS.ProcessEnv)
expect(cfg.panelPassword).toBe('pw')
expect(cfg.cpUrl).toBe('http://127.0.0.1:9000')
expect(cfg.tunnelZone).toBe('z.example')
expect(cfg.panelBindPort).toBe(9999)
})
it('throws when SESSION_SECRET is missing (fail-closed)', () => {
const { SESSION_SECRET: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when SESSION_SECRET is too short', () => {
expect(() => loadConfig({ ...base, SESSION_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow()
})
it('throws when BASE_DOMAIN is missing', () => {
const { BASE_DOMAIN: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when OPERATOR_ACCOUNT_ID is missing', () => {
const { OPERATOR_ACCOUNT_ID: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when PANEL_BIND_PORT is out of range', () => {
expect(() => loadConfig({ ...base, PANEL_BIND_PORT: '70000' } as NodeJS.ProcessEnv)).toThrow()
})
it('accepts loopback CP_URL hosts (127.0.0.0/8, ::1, localhost)', () => {
for (const url of ['http://127.0.0.1:8080', 'http://127.9.9.9:1', 'http://[::1]:8080', 'http://localhost:8080']) {
expect(loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv).cpUrl).toBe(url)
}
})
it('throws when CP_URL host is not loopback (anti-SSRF, fail-closed)', () => {
for (const url of ['http://evil.example.com:8080', 'http://169.254.169.254/', 'http://10.0.0.5:8080', 'http://8.8.8.8']) {
expect(() => loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv)).toThrow()
}
})
})