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

103 lines
4.5 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
import { createSlidingWindowLimiter, type RateLimiter } from '../src/security/rate-limit.js'
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader, TEST_SESSION_SECRET } from './helpers.js'
async function makeApp(overrides: Parameters<typeof buildApp>[0] extends infer T ? Partial<T> : never = {}) {
return buildApp({
config: makeConfig(),
cpClient: fakeCpClient(),
minter: fakeMinter(),
staticRoot: null,
...overrides,
})
}
describe('POST /login', () => {
it('returns 503 when PANEL_PASSWORD is unset (fail-closed)', async () => {
const app = await makeApp({ config: makeConfig({ panelPassword: undefined }) })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'anything' } })
expect(res.statusCode).toBe(503)
await app.close()
})
it('returns 401 on the wrong password (no cookie set)', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'wrong' } })
expect(res.statusCode).toBe(401)
expect(res.headers['set-cookie']).toBeUndefined()
await app.close()
})
it('returns 200 and sets an HttpOnly SameSite=Strict session cookie on success', async () => {
const app = await makeApp({ config: makeConfig({ panelPassword: 'secret-pw' }) })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'secret-pw' } })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ authenticated: true })
const cookie = String(res.headers['set-cookie'])
expect(cookie).toContain(`${SESSION_COOKIE_NAME}=`)
expect(cookie).toContain('HttpOnly')
expect(cookie).toContain('SameSite=Strict')
await app.close()
})
it('returns 429 when rate-limited (before credential work)', async () => {
const denyAll: RateLimiter = { allow: () => false }
const app = await makeApp({ rateLimiter: denyAll })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'whatever' } })
expect(res.statusCode).toBe(429)
await app.close()
})
it('returns 400 on a malformed body', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/login', payload: { notpassword: 1 } })
expect(res.statusCode).toBe(400)
await app.close()
})
// trustProxy: true (app.ts) makes req.ip read X-Forwarded-For, so the limiter buckets by REAL client
// IP. Without it every request would share the single 127.0.0.1 bucket (a global lockout DoS).
it('rate-limits per forwarded client IP: same X-Forwarded-For shares a budget, a different one is independent', async () => {
const limiter = createSlidingWindowLimiter(1, 60_000, () => 0) // one attempt per window per key
const app = await makeApp({ rateLimiter: limiter })
const login = (xff: string) =>
app.inject({ method: 'POST', url: '/login', headers: { 'x-forwarded-for': xff }, payload: { password: 'wrong' } })
// IP A, 1st attempt: budget available → reaches credential check → 401 (not throttled).
expect((await login('203.0.113.10')).statusCode).toBe(401)
// IP A, 2nd attempt: same bucket exhausted → 429.
expect((await login('203.0.113.10')).statusCode).toBe(429)
// IP B: its own bucket → 401, proving req.ip reflects the forwarded address (else it would be 429).
expect((await login('198.51.100.20')).statusCode).toBe(401)
await app.close()
})
})
describe('POST /logout', () => {
it('clears the session cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/logout' })
expect(res.statusCode).toBe(200)
expect(String(res.headers['set-cookie'])).toContain('Max-Age=0')
await app.close()
})
})
describe('GET /api/session', () => {
it('reports authenticated:false without a cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session' })
expect(res.json()).toEqual({ authenticated: false })
await app.close()
})
it('reports authenticated:true with a valid session cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session', headers: { cookie: authCookieHeader(TEST_SESSION_SECRET) } })
expect(res.json()).toEqual({ authenticated: true })
await app.close()
})
})