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'); }); });