diff --git a/src/config/index.ts b/src/config/index.ts index 77c0c2f..7651f6d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -151,7 +151,11 @@ export const ConfigSchema = z.object({ web: z.object({ enabled: z.boolean().default(true), port: z.number().default(3000), - }).default({ enabled: true, port: 3000 }), + /** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */ + authToken: z.string().min(8).optional(), + /** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */ + debugEnabled: z.boolean().default(false), + }).default({ enabled: true, port: 3000, debugEnabled: false }), }); export type Config = z.infer; @@ -186,6 +190,11 @@ export function loadConfig(): Config { rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true'; } + if (process.env.WEB_DASHBOARD_TOKEN !== undefined) { + if (!rawConfig.web) rawConfig.web = {}; + rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN; + } + if (!rawConfig.openrouter.apiKey) { throw new Error('OPENROUTER_API_KEY environment variable is required'); } @@ -199,5 +208,9 @@ export function loadConfig(): Config { throw new Error(`Invalid configuration:\n${errors}`); } + if (result.data.web.enabled && !result.data.web.authToken) { + throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled'); + } + return result.data; } diff --git a/src/web/auth.ts b/src/web/auth.ts new file mode 100644 index 0000000..6067cb5 --- /dev/null +++ b/src/web/auth.ts @@ -0,0 +1,86 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import { Router, type RequestHandler } from 'express'; +import { z } from 'zod'; + +const SessionRequestSchema = z.object({ token: z.string().min(1).max(4096) }).strict(); +const SESSION_COOKIE = 'reddit_bot_dashboard_session'; + +function tokenDigest(token: string): Buffer { + return createHash('sha256').update(token, 'utf8').digest(); +} + +function matchesToken(candidate: string | undefined, expected: string): boolean { + return candidate !== undefined && timingSafeEqual(tokenDigest(candidate), tokenDigest(expected)); +} + +function parseCookies(cookieHeader: string | undefined): Record { + if (!cookieHeader) return {}; + const cookies: Record = {}; + for (const part of cookieHeader.split(';')) { + const [name, ...value] = part.trim().split('='); + if (!name) continue; + try { + cookies[name] = decodeURIComponent(value.join('=')); + } catch { + // Ignore malformed cookies and continue checking the remaining values. + } + } + return cookies; +} + +function requestToken(authorization: string | undefined): string | undefined { + if (!authorization) return undefined; + const bearer = /^Bearer\s+(.+)$/i.exec(authorization); + return bearer?.[1]; +} + +export function createDashboardAuth(token: string): RequestHandler { + if (!token.trim()) throw new Error('A non-empty dashboard authentication token is required'); + return (req, res, next) => { + if (req.path === '/auth/session' || req.path === '/auth/login') return next(); + const supplied = requestToken(req.header('authorization')) + ?? parseCookies(req.header('cookie'))[SESSION_COOKIE]; + if (matchesToken(supplied, token)) return next(); + if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) { + res.redirect(302, '/auth/login'); + return; + } + res.status(401).set('WWW-Authenticate', 'Bearer realm="RedditBot Dashboard"') + .json({ error: 'Dashboard authentication required' }); + }; +} + +export function createDashboardSessionRouter(token: string): Router { + const router = Router(); + router.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
`); + }); + router.post('/auth/session', (req, res) => { + const payload = SessionRequestSchema.safeParse(req.body); + if (!payload.success) { + res.status(400).json({ error: 'Invalid dashboard authentication payload' }); + return; + } + if (!matchesToken(payload.data.token, token)) { + res.status(401).json({ error: 'Dashboard authentication required' }); + return; + } + const forwardedProtocol = req.get('x-forwarded-proto')?.split(',', 1)[0]?.trim(); + res.cookie(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'strict', + secure: req.secure || forwardedProtocol === 'https', + path: '/', + }); + res.status(204).end(); + }); + router.post('/auth/logout', (_req, res) => { + res.clearCookie(SESSION_COOKIE, { httpOnly: true, sameSite: 'strict', path: '/' }); + res.status(204).end(); + }); + return router; +} diff --git a/src/web/routes/debug.ts b/src/web/routes/debug.ts index 482ce35..1ece2a8 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(); @@ -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) { @@ -55,7 +49,6 @@ export function createDebugRouter(ctx: WebContext): Router { sameSite: c.sameSite, expires: c.expires, valueLen: c.value.length, - value: reveal ? c.value : undefined, })); } res.json(out); @@ -65,59 +58,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' }); - return; - } - try { - const chatUrl = req.body?.url; - if (!chatUrl) { - res.status(400).json({ error: 'url is required' }); - return; - } - const result = await ctx.services.chat.debugInspectDom(chatUrl); - res.json(result); - } catch (error) { - ctx.logger.error('DOM inspection failed:', error); - res.status(500).json({ error: String(error) }); - } - }); - router.post('/debug/check-responses', async (_req, res) => { if (!ctx.services.responseChecker) { res.status(500).json({ error: 'Response checker not initialized' }); diff --git a/src/web/server.ts b/src/web/server.ts index 046bd8b..405c6a2 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 { createDashboardAuth, createDashboardSessionRouter } from './auth.js'; +import { resolveDashboardAssets } from './dashboard-assets.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,29 @@ export class WebServer { } private setupRoutes(): void { + const dashboardToken = this.config.web.authToken; + if (!dashboardToken) { + throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN'); + } + this.app.use(express.json({ limit: '64kb' })); + this.app.use(createDashboardSessionRouter(dashboardToken)); + this.app.use(createDashboardAuth(dashboardToken)); + + const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory); + // 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); + return res.sendFile(dashboardAssets.indexFile); } next(); }); - 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()); + // Media can contain private chat attachments, so it is intentionally behind + // the same dashboard authentication middleware as the API and UI. + 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 +139,21 @@ 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')); + res.sendFile(dashboardAssets.indexFile); }); } @@ -200,10 +220,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); + }); }); } @@ -214,4 +242,10 @@ export class WebServer { this.server = null; } } + + /** Exposed for health probes and integration tests when configured with port 0. */ + getListeningPort(): number | null { + const address = this.server?.address(); + return address && typeof address !== 'string' ? (address as AddressInfo).port : null; + } } diff --git a/tests/config.test.ts b/tests/config.test.ts index 17ccd89..1a62839 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -294,7 +294,7 @@ 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 +303,7 @@ describe('loadConfig', () => { savedEnv[key] = process.env[key]; delete process.env[key]; } + process.env.WEB_DASHBOARD_TOKEN = 'test-dashboard-token'; const mockDir = '/tmp/test-config-' + Date.now(); fs.mkdirSync(mockDir, { recursive: true }); @@ -411,4 +412,34 @@ openrouter: expect(() => loadConfig()).toThrow('OPENROUTER_API_KEY environment variable is required'); }); + + it('requires a dashboard token when the dashboard is enabled', () => { + delete process.env.WEB_DASHBOARD_TOKEN; + 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_DASHBOARD_TOKEN'); + }); + + it('injects the dashboard token from the environment', () => { + process.env.WEB_DASHBOARD_TOKEN = 'a-secure-dashboard-token'; + 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().web.authToken).toBe('a-secure-dashboard-token'); + }); }); diff --git a/tests/dashboard-token-config.test.ts b/tests/dashboard-token-config.test.ts new file mode 100644 index 0000000..519b5f3 --- /dev/null +++ b/tests/dashboard-token-config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigSchema } from '../src/config/index.js'; + +describe('dashboard token configuration', () => { + it('accepts an eight-character local dashboard token', () => { + const result = ConfigSchema.safeParse({ + reddit: { subreddits: ['test'] }, + openrouter: { + apiKey: 'test-key', + imageAnalysisPrompt: 'test', + messageGenerationPrompt: 'test', + }, + web: { enabled: true, authToken: 'create01' }, + }); + + expect(result.success).toBe(true); + }); + + it('still rejects trivially short dashboard tokens', () => { + const result = ConfigSchema.safeParse({ + reddit: { subreddits: ['test'] }, + openrouter: { + apiKey: 'test-key', + imageAnalysisPrompt: 'test', + messageGenerationPrompt: 'test', + }, + web: { enabled: true, authToken: 'short' }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/tests/private-media-auth.test.ts b/tests/private-media-auth.test.ts new file mode 100644 index 0000000..6f6105d --- /dev/null +++ b/tests/private-media-auth.test.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createDashboardAuth } from '../src/web/auth.js'; + +const servers: Array> = []; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => new Promise((resolve) => server.close(() => resolve())), + ), + ); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('private media authentication', () => { + it('rejects anonymous media requests and permits an authenticated session', async () => { + const mediaDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-media-auth-')); + temporaryDirectories.push(mediaDirectory); + fs.writeFileSync(path.join(mediaDirectory, 'private.txt'), 'private-media'); + + const app = express(); + app.use(createDashboardAuth('create01')); + app.use('/media', express.static(mediaDirectory)); + + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}/media/private.txt`; + + const anonymousResponse = await fetch(url); + const authenticatedResponse = await fetch(url, { + headers: { Authorization: 'Bearer create01' }, + }); + + expect(anonymousResponse.status).toBe(401); + expect(authenticatedResponse.status).toBe(200); + expect(await authenticatedResponse.text()).toBe('private-media'); + }); +}); diff --git a/tests/web-auth-cutover.test.ts b/tests/web-auth-cutover.test.ts new file mode 100644 index 0000000..7bcda64 --- /dev/null +++ b/tests/web-auth-cutover.test.ts @@ -0,0 +1,119 @@ +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 React 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 anonymous = await Promise.all([ + fetch(`${running.baseUrl}/api/stats`), + fetch(`${running.baseUrl}/api/events/stream`), + fetch(`${running.baseUrl}/media/sample.txt`), + fetch(running.baseUrl, { redirect: 'manual' }), + ]); + expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 302]); + + const headers = { Authorization: `Bearer ${AUTH_TOKEN}` }; + const api = await fetch(`${running.baseUrl}/api/stats`, { headers }); + const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers }); + const root = await fetch(running.baseUrl, { headers }); + const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers }); + const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } }); + + expect(api.status).toBe(200); + expect(await media.text()).toBe('private media'); + expect(await root.text()).toContain('React dashboard'); + expect(await asset.text()).toBe('react-build'); + expect(await spa.text()).toContain('React dashboard'); + }); + + it('creates a secure session and keeps debug credentials unavailable', async () => { + const running = await startServer({ debugEnabled: true }); + runningServers.push(running); + const accepted = await fetch(`${running.baseUrl}/auth/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Forwarded-Proto': 'https' }, + 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('Secure'); + + 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 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(await cookies.text()).not.toContain('never-return-this-secret'); + expect(upload.status).toBe(404); + }); + + it('uses the legacy dashboard only when a React build is absent', async () => { + const running = await startServer({ includeReactBuild: false }); + runningServers.push(running); + const response = await fetch(running.baseUrl, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } }); + expect(await response.text()).toContain('Legacy dashboard'); + }); +}); diff --git a/tests/web-auth.test.ts b/tests/web-auth.test.ts new file mode 100644 index 0000000..f354223 --- /dev/null +++ b/tests/web-auth.test.ts @@ -0,0 +1,101 @@ +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createDashboardAuth, createDashboardSessionRouter } from '../src/web/auth.js'; +import { WebServer } from '../src/web/server.js'; +import { ConfigSchema } from '../src/config/index.js'; + +const servers: Array> = []; + +async function request( + app: express.Express, + path: string, + init: RequestInit = {}, +): Promise { + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return fetch(`http://127.0.0.1:${port}${path}`, init); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +describe('dashboard authentication', () => { + it('rejects protected dashboard and API requests without a token', async () => { + const app = express(); + app.use(createDashboardAuth('dashboard-secret')); + app.get('/api/stats', (_req, res) => res.json({ ok: true })); + + const response = await request(app, '/api/stats'); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Dashboard authentication required' }); + }); + + it('accepts a bearer token without exposing it in the URL', async () => { + const app = express(); + app.use(createDashboardAuth('dashboard-secret')); + app.get('/api/stats', (_req, res) => res.json({ ok: true })); + + const response = await request(app, '/api/stats', { + headers: { Authorization: 'Bearer dashboard-secret' }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); + + it('issues an HttpOnly session only for a schema-valid token payload', async () => { + const app = express(); + app.use(express.json()); + app.use(createDashboardSessionRouter('dashboard-secret')); + + const invalid = await request(app, '/auth/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: 123 }), + }); + const valid = await request(app, '/auth/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: 'dashboard-secret' }), + }); + + expect(invalid.status).toBe(400); + expect(valid.status).toBe(204); + expect(valid.headers.get('set-cookie')).toMatch(/HttpOnly/); + expect(valid.headers.get('set-cookie')).toMatch(/SameSite=Strict/); + }); + + it('does not mount debug endpoints unless explicitly enabled', async () => { + const config = ConfigSchema.parse({ + reddit: { subreddits: ['test'] }, + openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' }, + web: { enabled: true, port: 0, authToken: 'dashboard-secret-token' }, + }); + const activityRepo = { getRecent: () => [], insert: () => undefined, prune: () => undefined, countSentSkipped: () => 0 }; + const server = new WebServer( + 0, + config, + {} as never, + {} as never, + {} as never, + activityRepo as never, + { info: () => undefined } as never, + ); + server.start(); + const port = server.getListeningPort(); + expect(port).not.toBeNull(); + + const response = await fetch(`http://127.0.0.1:${port}/api/debug/cookies`, { + headers: { Authorization: 'Bearer dashboard-secret-token' }, + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'API endpoint not found' }); + server.stop(); + }); +});