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[0] extends infer T ? Partial : 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() }) })