192 lines
7.7 KiB
TypeScript
192 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 { 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');
|
|
|
|
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 } = {}): 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 } }),
|
|
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');
|
|
});
|
|
});
|