157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
import express from 'express';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { classifyRedditSessionError } from '../src/browser/client.js';
|
|
import { createLoginRouter } from '../src/web/routes/login.js';
|
|
|
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
|
|
|
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
|
|
const server = app.listen(0);
|
|
servers.push(server);
|
|
await new Promise<void>((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<void>((resolve) => server.close(() => resolve())),
|
|
),
|
|
);
|
|
});
|
|
|
|
function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable') {
|
|
const app = express();
|
|
app.use(
|
|
createLoginRouter({
|
|
services: {
|
|
browser: {
|
|
isSessionValid: async () => state === 'authenticated',
|
|
getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null,
|
|
getSessionState: () => state,
|
|
},
|
|
},
|
|
logger: { error: () => undefined },
|
|
} as never),
|
|
);
|
|
return app;
|
|
}
|
|
|
|
describe('Reddit session status', () => {
|
|
it('reports the verified Reddit account name for an authenticated session', async () => {
|
|
const response = await request(appWithBrowser('authenticated'), '/login/status');
|
|
|
|
expect(await response.json()).toEqual({
|
|
ready: true,
|
|
loggedIn: true,
|
|
status: 'authenticated',
|
|
username: 'example-account',
|
|
});
|
|
});
|
|
|
|
it('imports Reddit cookies, omits unrelated domains, and verifies the new session', async () => {
|
|
const importSession = vi.fn().mockResolvedValue(undefined);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(createLoginRouter({
|
|
services: {
|
|
sessionImporter: { import: importSession },
|
|
},
|
|
logger: { error: () => undefined, info: () => undefined },
|
|
} as never));
|
|
|
|
const response = await request(app, '/login/session-import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
cookies: [
|
|
{ name: 'reddit_session', value: 'secret', domain: '.reddit.com', path: '/', secure: true, httpOnly: true, sameSite: 'lax' },
|
|
{ name: 'tracking', value: 'ignore-me', domain: '.example.com', path: '/' },
|
|
],
|
|
matrix: {
|
|
homeserver: 'https://matrix.redditspace.com',
|
|
accessToken: 'matrix-secret',
|
|
userId: '@t2_bot:reddit.com',
|
|
capturedAt: 1_700_000_000_000,
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ ok: true, status: 'authenticated' });
|
|
expect(importSession).toHaveBeenCalledWith(
|
|
[expect.objectContaining({ name: 'reddit_session', domain: '.reddit.com' })],
|
|
expect.objectContaining({ accessToken: 'matrix-secret' }),
|
|
);
|
|
});
|
|
|
|
it('does not claim success when imported cookies do not authenticate', async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(createLoginRouter({
|
|
services: {
|
|
sessionImporter: {
|
|
import: async () => { throw new Error('Imported cookies did not authenticate with Reddit'); },
|
|
},
|
|
},
|
|
logger: { error: () => undefined, info: () => undefined },
|
|
} as never));
|
|
|
|
const response = await request(app, '/login/session-import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
cookies: [{ name: 'reddit_session', value: 'secret', domain: '.reddit.com', path: '/' }],
|
|
matrix: {
|
|
homeserver: 'https://matrix.redditspace.com',
|
|
accessToken: 'matrix-secret',
|
|
userId: '@t2_bot:reddit.com',
|
|
capturedAt: 1_700_000_000_000,
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(await response.json()).toEqual({ ok: false, error: 'Imported cookies did not authenticate with Reddit' });
|
|
});
|
|
|
|
it('reports when Reddit is unreachable instead of presenting it as logged out', async () => {
|
|
const response = await request(appWithBrowser('unreachable'), '/login/status');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({
|
|
ready: true,
|
|
loggedIn: false,
|
|
status: 'unreachable',
|
|
username: null,
|
|
});
|
|
});
|
|
|
|
it('reports when an interactive Reddit login is required', async () => {
|
|
const response = await request(appWithBrowser('login-required'), '/login/status');
|
|
|
|
expect(await response.json()).toMatchObject({
|
|
loggedIn: false,
|
|
status: 'login-required',
|
|
});
|
|
});
|
|
|
|
it('does not expose the retired interactive login endpoints', async () => {
|
|
const app = appWithBrowser('authenticated');
|
|
|
|
expect((await request(app, '/login/info')).status).toBe(404);
|
|
expect((await request(app, '/login/start', { method: 'POST' })).status).toBe(404);
|
|
});
|
|
|
|
it('classifies Reddit navigation failures as unreachable', () => {
|
|
expect(
|
|
classifyRedditSessionError(
|
|
new Error('page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at https://www.reddit.com/'),
|
|
),
|
|
).toBe('unreachable');
|
|
expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required');
|
|
});
|
|
});
|