From 8b1ec85f1d49ce78e69a846e9c57c4d408c8427a Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 15:46:11 +0100 Subject: [PATCH] feat: secure React dashboard cutover --- src/config/index.ts | 8 +- src/web/dashboard-auth.ts | 96 +++++++++++++++++ src/web/routes/debug.ts | 48 +-------- src/web/server.ts | 61 +++++++---- tests/config.test.ts | 71 ++++++++++-- tests/prompts.test.ts | 2 + tests/testUtils.ts | 2 + tests/web-auth-cutover.test.ts | 191 +++++++++++++++++++++++++++++++++ 8 files changed, 408 insertions(+), 71 deletions(-) create mode 100644 src/web/dashboard-auth.ts create mode 100644 tests/web-auth-cutover.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index 77c0c2f..bbb4307 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -151,7 +151,9 @@ export const ConfigSchema = z.object({ web: z.object({ enabled: z.boolean().default(true), port: z.number().default(3000), - }).default({ enabled: true, port: 3000 }), + authToken: z.string().min(8, 'Dashboard token must be at least 8 characters'), + debugEnabled: z.boolean().default(false), + }), }); export type Config = z.infer; @@ -186,6 +188,10 @@ export function loadConfig(): Config { rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true'; } + // Dashboard access is a deployment secret, not source-controlled config. + if (!rawConfig.web) rawConfig.web = {}; + rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN || rawConfig.web.authToken; + if (!rawConfig.openrouter.apiKey) { throw new Error('OPENROUTER_API_KEY environment variable is required'); } diff --git a/src/web/dashboard-auth.ts b/src/web/dashboard-auth.ts new file mode 100644 index 0000000..f458fdf --- /dev/null +++ b/src/web/dashboard-auth.ts @@ -0,0 +1,96 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { Application, NextFunction, Request, Response } from 'express'; + +const SESSION_COOKIE = 'redditbot_dashboard'; + +function tokenDigest(token: string): Buffer { + return createHash('sha256').update(token, 'utf8').digest(); +} + +export function tokensMatch(candidate: string, expected: string): boolean { + return timingSafeEqual(tokenDigest(candidate), tokenDigest(expected)); +} + +function bearerToken(req: Request): string | null { + const match = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? ''); + return match?.[1] ?? null; +} + +function cookieToken(req: Request): string | null { + const cookieHeader = req.get('cookie'); + if (!cookieHeader) return null; + + for (const part of cookieHeader.split(';')) { + const [name, ...valueParts] = part.trim().split('='); + if (name === SESSION_COOKIE) { + try { + return decodeURIComponent(valueParts.join('=')); + } catch { + return null; + } + } + } + return null; +} + +function requestToken(req: Request): string | null { + return bearerToken(req) ?? cookieToken(req); +} + +function isApiRequest(req: Request): boolean { + return req.path.startsWith('/api') || req.path.startsWith('/media'); +} + +export function createDashboardAuthMiddleware(expectedToken: string) { + return (req: Request, res: Response, next: NextFunction): void => { + const candidate = requestToken(req); + if (candidate !== null && tokensMatch(candidate, expectedToken)) { + next(); + return; + } + + if (isApiRequest(req)) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + res.redirect(302, '/auth/login'); + }; +} + +export function configureDashboardSessionRoutes(app: Application, expectedToken: string): void { + app.get('/auth/login', (_req, res) => { + res + .set('Cache-Control', 'no-cache, no-store, must-revalidate') + .set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'") + .type('html') + .send(` + +Dashboard sign in +

Dashboard sign in

+`); + }); + + app.post('/auth/session', (req, res) => { + const body = req.body as { token?: unknown } | undefined; + const candidate = typeof body?.token === 'string' ? body.token : ''; + if (!tokensMatch(candidate, expectedToken)) { + res.status(401).json({ error: 'Invalid access token' }); + return; + } + + const forwardedProtocol = req.get('x-forwarded-proto')?.split(',', 1)[0]?.trim(); + res.cookie(SESSION_COOKIE, candidate, { + httpOnly: true, + sameSite: 'strict', + secure: req.secure || forwardedProtocol === 'https', + path: '/', + }); + res.status(204).end(); + }); + + app.post('/auth/logout', (_req, res) => { + res.clearCookie(SESSION_COOKIE, { httpOnly: true, sameSite: 'strict', path: '/' }); + res.status(204).end(); + }); +} diff --git a/src/web/routes/debug.ts b/src/web/routes/debug.ts index 482ce35..d3a6f5e 100644 --- a/src/web/routes/debug.ts +++ b/src/web/routes/debug.ts @@ -2,11 +2,6 @@ import { Router } from 'express'; import path from 'path'; import fs from 'fs'; import type { WebContext } from '../context.js'; -import { - MatrixClient, - saveCredentials, - type MatrixCredentials, -} from '../../services/chat/index.js'; export function createDebugRouter(ctx: WebContext): Router { const router = Router(); @@ -26,10 +21,10 @@ export function createDebugRouter(ctx: WebContext): Router { res.sendFile(p); }); - // Dumps the browser context's cookies (names + metadata only — values are - // hashed) so we can tell which session/OAuth cookies are set after the + // Dumps the browser context's cookie names and non-secret metadata so we + // can tell which session/OAuth cookies are set after the // auto-login and which the chat surface might be missing. - router.get('/debug/cookies', async (req, res) => { + router.get('/debug/cookies', async (_req, res) => { const browser = ctx.services.browser; if (!browser) { res.status(500).json({ error: 'Browser client not initialized' }); @@ -41,7 +36,6 @@ export function createDebugRouter(ctx: WebContext): Router { return; } try { - const reveal = req.query.reveal === '1'; const urls = ['https://www.reddit.com', 'https://chat.reddit.com', 'https://matrix.redditspace.com']; const out: Record = {}; for (const u of urls) { @@ -54,8 +48,6 @@ export function createDebugRouter(ctx: WebContext): Router { secure: c.secure, sameSite: c.sameSite, expires: c.expires, - valueLen: c.value.length, - value: reveal ? c.value : undefined, })); } res.json(out); @@ -65,40 +57,6 @@ export function createDebugRouter(ctx: WebContext): Router { } }); - // Accepts a Matrix credentials JSON body, persists it to the cache file, - // and swaps it into the running ChatService. Lets us seed the deployed bot - // from a sniff run on a developer's machine when the in-container sniff - // can't reach an authed /chat/ surface and noVNC isn't an option. - // - // Body shape: { homeserver, accessToken, userId?, capturedAt? } - router.post('/debug/matrix-credentials', (req, res) => { - const body = (req.body ?? {}) as Partial; - if (!body.homeserver || !body.accessToken) { - res.status(400).json({ error: 'homeserver and accessToken are required' }); - return; - } - const creds: MatrixCredentials = { - homeserver: body.homeserver, - accessToken: body.accessToken, - userId: body.userId ?? '', - capturedAt: body.capturedAt ?? Date.now(), - }; - try { - saveCredentials(creds); - ctx.logger.info( - `[matrix] credentials uploaded via /api/debug/matrix-credentials (user=${creds.userId || 'unknown'})`, - ); - if (ctx.services.chat) { - ctx.services.chat.setMatrixClient(new MatrixClient(creds, ctx.logger)); - ctx.logger.info('[matrix] uploaded credentials swapped into running ChatService'); - } - res.json({ success: true, homeserver: creds.homeserver, userId: creds.userId }); - } catch (error) { - ctx.logger.error('Matrix credential upload failed:', error); - res.status(500).json({ error: String(error) }); - } - }); - router.post('/debug/inspect-dom', async (req, res) => { if (!ctx.services.chat) { res.status(500).json({ error: 'Chat service not initialized' }); diff --git a/src/web/server.ts b/src/web/server.ts index 046bd8b..03bc4ba 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,6 +1,7 @@ import express from 'express'; import path from 'path'; import type { Server } from 'http'; +import type { AddressInfo } from 'node:net'; import type { Logger } from '../utils/logger.js'; import type { Config } from '../config/index.js'; import type { @@ -31,6 +32,8 @@ import { createLogsRouter } from './routes/logs.js'; import { createAnalyticsRouter } from './routes/analytics.js'; import { createConfigRouter } from './routes/config.js'; import { createLoginRouter } from './routes/login.js'; +import { resolveDashboardAssets } from './dashboard-assets.js'; +import { configureDashboardSessionRoutes, createDashboardAuthMiddleware } from './dashboard-auth.js'; export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js'; @@ -63,6 +66,7 @@ export class WebServer { private activityLogRepo: ActivityLogRepository, private logger: Logger, broadcaster?: LogBroadcaster, + private workspaceDirectory = process.cwd(), ) { this.broadcaster = broadcaster ?? new LogBroadcaster(); this.activity = new ActivityLog(activityLogRepo); @@ -105,19 +109,21 @@ export class WebServer { } private setupRoutes(): void { - // index.html serves the entire inline JS — never cache it. - this.app.use((req, res, next) => { - if (req.path === '/' || req.path === '/index.html') { - const p = path.join(process.cwd(), 'public', 'index.html'); - res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); - return res.sendFile(p); - } - next(); + const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory); + this.app.use(express.json({ limit: '32kb' })); + configureDashboardSessionRoutes(this.app, this.config.web.authToken); + this.app.use(createDashboardAuthMiddleware(this.config.web.authToken)); + + // The shell may change between deployments, so never cache index.html. + this.app.get(['/', '/index.html'], (_req, res) => { + res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.sendFile(dashboardAssets.indexFile); }); - this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media'))); - this.app.use(express.static(path.join(process.cwd(), 'public'))); - this.app.use(express.json()); + this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { + index: false, + })); + this.app.use(express.static(dashboardAssets.directory, { index: false })); const ctx = this.context(); this.app.use('/api', createStatsRouter(ctx)); @@ -125,15 +131,26 @@ export class WebServer { this.app.use('/api', createConversationsRouter(ctx)); this.app.use('/api', createMediaRouter(ctx)); this.app.use('/api', createControlRouter(ctx)); - this.app.use('/api', createDebugRouter(ctx)); + if (this.config.web.debugEnabled) { + this.app.use('/api', createDebugRouter(ctx)); + } this.app.use('/api', createLogsRouter(ctx)); this.app.use('/api', createAnalyticsRouter(ctx)); this.app.use('/api', createConfigRouter(ctx)); this.app.use('/api', createLoginRouter(ctx)); + this.app.use('/api', (_req, res) => { + res.status(404).json({ error: 'API endpoint not found' }); + }); + // SPA catch-all. - this.app.get(/.*/, (_req, res) => { - res.sendFile(path.join(process.cwd(), 'public', 'index.html')); + this.app.get(/.*/, (req, res) => { + if (!req.get('accept')?.includes('text/html')) { + res.status(404).end(); + return; + } + res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.sendFile(dashboardAssets.indexFile); }); } @@ -200,10 +217,18 @@ export class WebServer { this.messagesThisHour = messagesThisHour; } - start(): void { - this.server = this.app.listen(this.port, () => { - this.logger.info(`Web dashboard running at http://localhost:${this.port}`); - this.broadcaster.push('info', `Web dashboard started on port ${this.port}`); + start(): Promise { + return new Promise((resolve, reject) => { + const server = this.app.listen(this.port); + this.server = server; + server.once('error', reject); + server.once('listening', () => { + const address = server.address() as AddressInfo | null; + const listeningPort = address?.port ?? this.port; + this.logger.info(`Web dashboard running at http://localhost:${listeningPort}`); + this.broadcaster.push('info', `Web dashboard started on port ${listeningPort}`); + resolve(listeningPort); + }); }); } diff --git a/tests/config.test.ts b/tests/config.test.ts index 17ccd89..61cb598 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,7 +13,7 @@ function minimalConfig(overrides: Record = {}): Record | undefined) }, }; } @@ -54,7 +54,7 @@ describe('ConfigSchema', () => { const result = ConfigSchema.safeParse({ reddit: { subreddits: [{ name: 'test', tone }] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, - rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, + rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' }, }); expect(result.success).toBe(true); } @@ -64,7 +64,7 @@ describe('ConfigSchema', () => { const result = ConfigSchema.safeParse({ reddit: { subreddits: [{ name: 'test', tone: 'bananas' }] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, - rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, + rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' }, }); expect(result.success).toBe(false); }); @@ -73,7 +73,7 @@ describe('ConfigSchema', () => { const result = ConfigSchema.safeParse({ reddit: { subreddits: [{ name: 'test', scripted: 'Hello {author}' }] }, openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, - rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, + rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' }, }); expect(result.success).toBe(true); }); @@ -130,7 +130,7 @@ describe('ConfigSchema', () => { return { reddit: { subreddits: ['test'] }, openrouter, - rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {}, + rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' }, }; } @@ -270,6 +270,22 @@ describe('ConfigSchema', () => { if (result.success) { expect(result.data.web.enabled).toBe(true); expect(result.data.web.port).toBe(3000); + expect(result.data.web.debugEnabled).toBe(false); + } + }); + + it('accepts an eight-character dashboard token', () => { + const result = ConfigSchema.safeParse(minimalConfig({ web: { authToken: 'create01' } })); + + expect(result.success).toBe(true); + }); + + it('rejects dashboard tokens shorter than eight characters', () => { + const result = ConfigSchema.safeParse(minimalConfig({ web: { authToken: 'short7!' } })); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join('.') === 'web.authToken')).toBe(true); } }); }); @@ -284,7 +300,7 @@ describe('ConfigSchema', () => { database: {}, media: {}, strategies: {}, - web: {}, + web: { authToken: 'create01' }, }); expect(result.success).toBe(true); }); @@ -294,7 +310,13 @@ describe('ConfigSchema', () => { describe('loadConfig', () => { const originalCwd = process.cwd; const savedEnv: Record = {}; - const trackedEnvKeys = ['OPENROUTER_API_KEY', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS']; + const trackedEnvKeys = [ + 'OPENROUTER_API_KEY', + 'REDDIT_USERNAME', + 'REDDIT_PASSWORD', + 'BROWSER_HEADLESS', + 'WEB_DASHBOARD_TOKEN', + ]; beforeEach(() => { // Snapshot then clear any env vars that loadConfig consults — these may be @@ -303,6 +325,7 @@ describe('loadConfig', () => { savedEnv[key] = process.env[key]; delete process.env[key]; } + process.env.WEB_DASHBOARD_TOKEN = 'create01'; const mockDir = '/tmp/test-config-' + Date.now(); fs.mkdirSync(mockDir, { recursive: true }); @@ -384,6 +407,40 @@ const config = loadConfig(); expect(config.browser.headless).toBe(true); }); + it('loads the dashboard token from WEB_DASHBOARD_TOKEN', () => { + process.env.WEB_DASHBOARD_TOKEN = 'create01'; + const configPath = path.join(process.cwd(), 'config.yaml'); + fs.writeFileSync(configPath, ` +reddit: + subreddits: + - test +openrouter: + apiKey: key + imageAnalysisPrompt: analyze this + messageGenerationPrompt: write message +`); + + const config = loadConfig(); + + expect(config.web.authToken).toBe('create01'); + }); + + it('rejects a dashboard token shorter than eight characters', () => { + process.env.WEB_DASHBOARD_TOKEN = 'short7!'; + const configPath = path.join(process.cwd(), 'config.yaml'); + fs.writeFileSync(configPath, ` +reddit: + subreddits: + - test +openrouter: + apiKey: key + imageAnalysisPrompt: analyze this + messageGenerationPrompt: write message +`); + + expect(() => loadConfig()).toThrow('web.authToken'); + }); + it('rejects config with invalid values', () => { const configPath = path.join(process.cwd(), 'config.yaml'); fs.writeFileSync(configPath, ` diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index d304ebd..8aa392e 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -198,6 +198,8 @@ describe('Prompt Testing', () => { const rawConfig = yaml.parse(content); rawConfig.openrouter = rawConfig.openrouter || {}; rawConfig.openrouter.apiKey = rawConfig.openrouter.apiKey || 'test'; + rawConfig.web = rawConfig.web || {}; + rawConfig.web.authToken = 'create01'; return ConfigSchema.parse(rawConfig); } diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 72de47f..32869bb 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -88,6 +88,8 @@ export function getTestConfig(overrides?: Partial): Config { web: { enabled: true, port: 3000, + authToken: 'create01', + debugEnabled: false, ...overrides?.web, }, }; diff --git a/tests/web-auth-cutover.test.ts b/tests/web-auth-cutover.test.ts new file mode 100644 index 0000000..64252bd --- /dev/null +++ b/tests/web-auth-cutover.test.ts @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { BrowserContext } from 'playwright'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BrowserClient } from '../src/browser/client.js'; +import { ActivityLogRepository } from '../src/database/repositories.js'; +import { WebServer } from '../src/web/server.js'; +import { createMockLogger } from './mockLogger.js'; +import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js'; + +const AUTH_TOKEN = 'create01'; + +interface RunningServer { + baseUrl: string; + server: WebServer; + workspace: string; +} + +function createWorkspace(includeReactBuild = true): string { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-web-')); + fs.mkdirSync(path.join(workspace, 'public'), { recursive: true }); + fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '

Legacy dashboard

'); + fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true }); + fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media'); + + if (includeReactBuild) { + fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true }); + fs.writeFileSync( + path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), + '

React dashboard

', + ); + fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets', 'app.js'), 'react-build'); + } + + return workspace; +} + +async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise { + const database = createTestDb(); + const repositories = createTestRepositories(database); + const workspace = createWorkspace(options.includeReactBuild); + const server = new WebServer( + 0, + getTestConfig({ web: { enabled: true, port: 0, authToken: AUTH_TOKEN, debugEnabled: options.debugEnabled ?? false } }), + repositories.postsRepo, + repositories.messagesRepo, + repositories.successfulMessagesRepo, + new ActivityLogRepository(database), + createMockLogger(), + undefined, + workspace, + ); + const port = await server.start(); + + return { baseUrl: `http://127.0.0.1:${port}`, server, workspace }; +} + +describe('authenticated dashboard cutover', () => { + const runningServers: RunningServer[] = []; + + afterEach(() => { + for (const running of runningServers) { + running.server.stop(); + fs.rmSync(running.workspace, { recursive: true, force: true }); + } + runningServers.length = 0; + }); + + it('protects API, SSE, media, React assets, and SPA navigation', async () => { + const running = await startServer(); + runningServers.push(running); + + const apiResponse = await fetch(`${running.baseUrl}/api/stats`); + const sseResponse = await fetch(`${running.baseUrl}/api/events/stream`); + const mediaResponse = await fetch(`${running.baseUrl}/media/sample.txt`); + const pageResponse = await fetch(running.baseUrl, { redirect: 'manual' }); + + expect(apiResponse.status).toBe(401); + await expect(apiResponse.json()).resolves.toEqual({ error: 'Authentication required' }); + expect(sseResponse.status).toBe(401); + expect(mediaResponse.status).toBe(401); + expect(pageResponse.status).toBe(302); + expect(pageResponse.headers.get('location')).toBe('/auth/login'); + + const wrongBearer = await fetch(`${running.baseUrl}/api/stats`, { + headers: { Authorization: 'Bearer incorrect-token' }, + }); + expect(wrongBearer.status).toBe(401); + + const headers = { Authorization: `Bearer ${AUTH_TOKEN}` }; + const authenticatedApi = await fetch(`${running.baseUrl}/api/stats`, { headers }); + const authenticatedMedia = await fetch(`${running.baseUrl}/media/sample.txt`, { headers }); + const authenticatedPage = await fetch(running.baseUrl, { headers }); + const authenticatedAsset = await fetch(`${running.baseUrl}/assets/app.js`, { headers }); + const authenticatedSpaRoute = await fetch(`${running.baseUrl}/conversations/example`, { + headers: { ...headers, Accept: 'text/html' }, + }); + + expect(authenticatedApi.status).toBe(200); + expect(await authenticatedMedia.text()).toBe('private media'); + expect(await authenticatedPage.text()).toContain('React dashboard'); + expect(await authenticatedAsset.text()).toBe('react-build'); + expect(await authenticatedSpaRoute.text()).toContain('React dashboard'); + }); + + it('creates a secure browser session without accepting a wrong token', async () => { + const running = await startServer(); + runningServers.push(running); + + const rejected = await fetch(`${running.baseUrl}/auth/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: 'incorrect-token' }), + }); + expect(rejected.status).toBe(401); + expect(rejected.headers.get('set-cookie')).toBeNull(); + + const accepted = await fetch(`${running.baseUrl}/auth/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: AUTH_TOKEN }), + }); + const setCookie = accepted.headers.get('set-cookie'); + + expect(accepted.status).toBe(204); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('SameSite=Strict'); + expect(setCookie).toContain('Path=/'); + + const cookie = setCookie?.split(';', 1)[0] ?? ''; + const authenticated = await fetch(`${running.baseUrl}/api/stats`, { headers: { Cookie: cookie } }); + expect(authenticated.status).toBe(200); + }); + + it('returns JSON for unknown API routes and keeps debug routes opt-in', async () => { + const running = await startServer(); + runningServers.push(running); + const headers = { Authorization: `Bearer ${AUTH_TOKEN}` }; + + const missingApi = await fetch(`${running.baseUrl}/api/not-real`, { headers }); + const disabledDebug = await fetch(`${running.baseUrl}/api/debug/cookies`, { headers }); + + expect(missingApi.status).toBe(404); + await expect(missingApi.json()).resolves.toEqual({ error: 'API endpoint not found' }); + expect(disabledDebug.status).toBe(404); + }); + + it('never reveals browser cookie values and does not accept uploaded Matrix credentials', async () => { + const running = await startServer({ debugEnabled: true }); + runningServers.push(running); + const browserContext = { + cookies: async () => [{ + name: 'reddit_session', + value: 'never-return-this-secret', + domain: '.reddit.com', + path: '/', + expires: -1, + httpOnly: true, + secure: true, + sameSite: 'Lax' as const, + }], + } as unknown as BrowserContext; + running.server.setBrowserClient({ getContext: () => browserContext } as unknown as BrowserClient); + const headers = { Authorization: `Bearer ${AUTH_TOKEN}` }; + + const cookies = await fetch(`${running.baseUrl}/api/debug/cookies?reveal=1`, { headers }); + const cookiePayload = await cookies.text(); + const upload = await fetch(`${running.baseUrl}/api/debug/matrix-credentials`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ homeserver: 'https://matrix.example', accessToken: 'secret' }), + }); + + expect(cookies.status).toBe(200); + expect(cookiePayload).not.toContain('never-return-this-secret'); + expect(upload.status).toBe(404); + }); + + it('serves the legacy dashboard only when no React build exists', async () => { + const running = await startServer({ includeReactBuild: false }); + runningServers.push(running); + + const response = await fetch(running.baseUrl, { + headers: { Authorization: `Bearer ${AUTH_TOKEN}` }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('Legacy dashboard'); + }); +});