Reviewers were asked to include or exclude learning trajectories while only seeing metadata — credit class, confidence, status. The decisions and their redacted context turns were already captured and stored, but no endpoint returned them, so the evidence was unreachable from the UI. Add GET /api/learning/trajectories/:id and an expandable row on the learning page that fetches on demand. Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit and imageOutcomeAt. The learning loop added these to SentMessageRecord and they flow out of /api/messages, but the strict contract rejected them, breaking the dashboard. Enable generation with shadow mode off so eligible examples reach reply prompts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.4 KiB
TypeScript
209 lines
7.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: {
|
|
accountAuth: {
|
|
ensureFullyAuthenticated: async () => state === 'authenticated',
|
|
getSnapshot: () => ({ redditUsername: state === 'authenticated' ? 'example-account' : null }),
|
|
},
|
|
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('removes an account and reports the account it deleted', async () => {
|
|
const remove = vi.fn().mockResolvedValue({ accountId: 't2_alice', redditUsername: 'alice-bot' });
|
|
const app = express();
|
|
app.use(createLoginRouter({
|
|
services: { redditAccounts: { remove } },
|
|
logger: { error: () => undefined, info: () => undefined },
|
|
} as never));
|
|
|
|
const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({
|
|
ok: true,
|
|
account: { accountId: 't2_alice', redditUsername: 'alice-bot' },
|
|
});
|
|
expect(remove).toHaveBeenCalledWith('t2_alice');
|
|
});
|
|
|
|
it('answers 404 when the account to remove does not exist', async () => {
|
|
const app = express();
|
|
app.use(createLoginRouter({
|
|
services: {
|
|
redditAccounts: { remove: async () => { throw new Error('Reddit account not found'); } },
|
|
},
|
|
logger: { error: () => undefined, info: () => undefined },
|
|
} as never));
|
|
|
|
const response = await request(app, '/accounts/t2_missing', { method: 'DELETE' });
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ ok: false, error: 'Reddit account not found' });
|
|
});
|
|
|
|
it('reports a failed removal as a server error rather than a silent success', async () => {
|
|
const app = express();
|
|
app.use(createLoginRouter({
|
|
services: {
|
|
redditAccounts: { remove: async () => { throw new Error('database is locked'); } },
|
|
},
|
|
logger: { error: () => undefined, info: () => undefined },
|
|
} as never));
|
|
|
|
const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ ok: false, error: 'database is locked' });
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|