Files
RedditBot/tests/web-auth-cutover.test.ts

172 lines
7.7 KiB
TypeScript

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 { OverviewResponseSchema } from '../src/web/agent-api-contracts.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'), '<h1>Legacy dashboard</h1>');
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
fs.mkdirSync(path.join(workspace, 'data', 'media-library'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'data', 'media-library', 'library.txt'), 'private library media');
if (includeReactBuild) {
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets', 'app.js'), 'react-build');
}
return workspace;
}
async function startServer(options: {
debugEnabled?: boolean;
includeReactBuild?: boolean;
agentReadToken?: string;
agentControlToken?: string;
} = {}): Promise<RunningServer> {
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,
agentReadToken: options.agentReadToken,
agentControlToken: options.agentControlToken,
} }),
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}/library-media/library.txt`),
fetch(running.baseUrl, { redirect: 'manual' }),
]);
expect(anonymous.map((response) => response.status)).toEqual([401, 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 libraryMedia = await fetch(`${running.baseUrl}/library-media/library.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 libraryMedia.text()).toBe('private library 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('exposes public liveness and scoped discovery/control APIs', async () => {
const running = await startServer({
agentReadToken: 'agent-read-token',
agentControlToken: 'agent-control-token',
});
runningServers.push(running);
running.server.setAccountAuth({
isFullyAuthenticated: () => true,
} as never);
const health = await fetch(`${running.baseUrl}/healthz`);
const openapi = await fetch(`${running.baseUrl}/api/openapi.json`, {
headers: { Authorization: 'Bearer agent-read-token' },
});
const overview = await fetch(`${running.baseUrl}/api/v1/overview`, {
headers: { Authorization: 'Bearer agent-read-token' },
});
const forbiddenStart = await fetch(`${running.baseUrl}/api/v1/control/start`, {
method: 'POST',
headers: { Authorization: 'Bearer agent-read-token' },
});
const allowedStart = await fetch(`${running.baseUrl}/api/v1/control/start`, {
method: 'POST',
headers: { Authorization: 'Bearer agent-control-token' },
});
const allowedStop = await fetch(`${running.baseUrl}/api/v1/control/stop`, {
method: 'POST',
headers: { Authorization: 'Bearer agent-control-token' },
});
expect(health.status).toBe(200);
expect(await health.json()).toMatchObject({ status: 'ok', service: 'reddit-bot' });
expect(openapi.status).toBe(200);
expect(await openapi.json()).toMatchObject({ openapi: '3.1.0' });
expect(OverviewResponseSchema.safeParse(await overview.json()).success).toBe(true);
expect(forbiddenStart.status).toBe(403);
expect(await allowedStart.json()).toMatchObject({ success: true, running: true, changed: true });
expect(await allowedStop.json()).toMatchObject({ success: true, running: false, changed: true });
});
});