Merge branch 'codex/native-login-handoff' into main

# Conflicts:
#	public/index.html
#	src/bootstrap.ts
#	src/services/chat/service.ts
#	tests/chat-service-username-persist.test.ts
This commit is contained in:
jjbrunton committed 2026-08-08 16:46:16 +01:00
commit 50985b52f1
28 files changed
+987 -124

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import {
AccountAuthContext,
authenticateCurrentAccount,
} from '../src/services/account-auth-context.js';
import { createMockLogger } from './mockLogger.js';
describe('AccountAuthContext', () => {
it('does not attempt Matrix capture until Reddit identity is authoritative', async () => {
const refreshMatrixCredentials = vi.fn();
await expect(authenticateCurrentAccount({
getRedditUsername: vi.fn().mockResolvedValue(null),
refreshMatrixCredentials,
})).resolves.toBeNull();
expect(refreshMatrixCredentials).not.toHaveBeenCalled();
});
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
const auth = new AccountAuthContext(createMockLogger());
const authenticate = vi.fn().mockResolvedValue({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
auth.setAuthenticator(authenticate);
expect(auth.isFullyAuthenticated()).toBe(false);
expect(auth.getSnapshot().status).toBe('login-required');
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(true);
expect(auth.isFullyAuthenticated()).toBe(true);
expect(auth.getSnapshot()).toMatchObject({
status: 'authenticated',
accountId: '@t2_account:reddit.com',
});
});
it('coalesces concurrent authentication checks globally', async () => {
const auth = new AccountAuthContext(createMockLogger());
let resolveAuth: ((account: {
accountId: string;
redditUsername: string;
matrixUserId: string;
}) => void) | undefined;
const authenticate = vi.fn(() => new Promise((resolve) => { resolveAuth = resolve; }));
auth.setAuthenticator(authenticate);
const first = auth.ensureFullyAuthenticated();
const second = auth.ensureFullyAuthenticated();
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
resolveAuth?.({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
});
it('invalidates the whole account context when either login layer expires', async () => {
const auth = new AccountAuthContext(createMockLogger());
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
auth.invalidate('matrix token rejected');
expect(auth.isFullyAuthenticated()).toBe(false);
expect(auth.getSnapshot()).toMatchObject({
status: 'login-required',
accountId: null,
reason: 'matrix token rejected',
});
});
it('fails closed when the authentication lease expires', () => {
let now = 1_000;
const auth = new AccountAuthContext(createMockLogger(), {
leaseMs: 100,
retryDelayMs: 1_000,
now: () => now,
});
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
now = 1_101;
expect(auth.isFullyAuthenticated()).toBe(false);
expect(auth.getSnapshot().status).toBe('login-required');
});
it('throttles failed global authentication checks', async () => {
let now = 1_000;
const auth = new AccountAuthContext(createMockLogger(), {
leaseMs: 100,
retryDelayMs: 1_000,
now: () => now,
});
const authenticate = vi.fn().mockResolvedValue(null);
auth.setAuthenticator(authenticate);
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
expect(authenticate).toHaveBeenCalledOnce();
now = 2_001;
await auth.ensureFullyAuthenticated();
expect(authenticate).toHaveBeenCalledTimes(2);
});
});
+3 -6
View File
@@ -57,7 +57,7 @@ describe('BrowserClient external Chrome', () => {
expect(runtime.launchPersistentContext).toHaveBeenCalledOnce();
});
it('opens the login page for a human without submitting credentials', async () => {
it('does not control external Chrome during native human login', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const page = {
goto: vi.fn().mockResolvedValue(undefined),
@@ -79,11 +79,8 @@ describe('BrowserClient external Chrome', () => {
await client.initialize();
await expect(client.login()).resolves.toBe(false);
expect(page.bringToFront).toHaveBeenCalledOnce();
expect(page.goto).toHaveBeenCalledWith(
'https://www.reddit.com/login',
{ waitUntil: 'domcontentloaded' },
);
expect(page.bringToFront).not.toHaveBeenCalled();
expect(page.goto).not.toHaveBeenCalled();
expect(page.fill).not.toHaveBeenCalled();
expect(client.getSessionState()).toBe('login-required');
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import os from 'node:os';
import path from 'node:path';
import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
describe('ChatService account authentication gate', () => {
it('does not make Matrix requests before full account authentication', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient({
homeserver: 'https://matrix.example.test',
accessToken: 'cached-but-unverified',
userId: '@t2_account:reddit.com',
capturedAt: 1,
}, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn();
const cache = new ChatCache(
logger,
path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`),
);
const auth = { isFullyAuthenticated: vi.fn(() => false) };
const service = new ChatService(
matrix,
logger,
undefined,
undefined,
undefined,
undefined,
cache,
undefined,
undefined,
auth,
);
await service.refreshTopChats();
expect(matrix.sync).not.toHaveBeenCalled();
});
});
+6 -5
View File
@@ -6,6 +6,7 @@ import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = {
homeserver: 'https://matrix.example.test',
@@ -29,7 +30,7 @@ function buildService() {
logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
return { svc, logger };
}
@@ -97,7 +98,7 @@ describe('ChatService invitation cooldown', () => {
try {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
const cache1 = new ChatCache(logger, cacheFile);
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1);
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1, undefined, undefined, authenticatedAccountAuth);
svc1.setInvitationCooldown(
new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null),
);
@@ -105,7 +106,7 @@ describe('ChatService invitation cooldown', () => {
await cache1.save();
const cache2 = new ChatCache(logger, cacheFile);
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2);
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2, undefined, undefined, authenticatedAccountAuth);
await svc2.loadCache();
const restored = svc2.getInvitationCooldown();
expect(restored).not.toBeNull();
@@ -144,7 +145,7 @@ describe('ChatService invitation cooldown', () => {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
// Cooldown fires before loadCache() — the in-memory chats are still [].
svc.setInvitationCooldown(
@@ -178,7 +179,7 @@ describe('ChatService invitation cooldown', () => {
logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
const result = await svc.sendChatMessage('!room:example.test', 'hi');
expect(result).toBe(false);
+4 -3
View File
@@ -8,6 +8,7 @@ import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { MediaService } from '../src/services/media.js';
import type { MediaDownloader } from '../src/services/media.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = {
homeserver: 'https://matrix.example.test',
@@ -60,7 +61,7 @@ describe('ChatService delta sync — media attachment', () => {
const downloader = buildDownloader();
const media = new MediaService(downloader, logger, tmpDir);
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache);
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await svc.getActiveChats();
@@ -128,7 +129,7 @@ describe('ChatService delta sync — media attachment', () => {
const media = new MediaService(buildDownloader(), logger, tmpDir);
const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache);
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await svc.loadCache();
// refreshTopChats always runs a /sync (unlike getActiveChats, which can
@@ -176,7 +177,7 @@ describe('ChatService delta sync — media attachment', () => {
logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await expect(svc.getActiveChats()).resolves.toBeDefined();
});
});
@@ -8,6 +8,7 @@ import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = {
homeserver: 'https://matrix.example.test',
@@ -47,6 +48,7 @@ describe('ChatService username persistence', () => {
tmpCache(logger),
undefined,
repo,
authenticatedAccountAuth,
);
expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice');
@@ -61,6 +63,9 @@ describe('ChatService username persistence', () => {
logger,
undefined, undefined, undefined, undefined,
tmpCache(logger),
undefined,
undefined,
authenticatedAccountAuth,
);
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
});
@@ -95,6 +100,7 @@ describe('ChatService username persistence', () => {
cache,
undefined,
repo,
authenticatedAccountAuth,
);
await svc.loadCache();
@@ -145,6 +151,9 @@ describe('ChatService username persistence', () => {
tokenRefresher,
usernameResolver,
cache,
undefined,
undefined,
authenticatedAccountAuth,
);
await svc.enrichUsernames();
+35
View File
@@ -0,0 +1,35 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createControlRouter } from '../src/web/routes/control.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
));
});
describe('bot control account authentication gate', () => {
it('refuses to start the bot until full account authentication', async () => {
const setBotRunning = vi.fn();
const app = express();
app.use(createControlRouter({
services: { accountAuth: { isFullyAuthenticated: () => false } },
setBotRunning,
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ success: false, authenticated: false });
expect(setBotRunning).not.toHaveBeenCalled();
});
});
+59 -1
View File
@@ -1,6 +1,6 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { classifyRedditSessionError } from '../src/browser/client.js';
import { createLoginRouter } from '../src/web/routes/login.js';
@@ -14,6 +14,14 @@ async function request(app: express.Express, path: string): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`);
}
async function post(app: express.Express, path: string): 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}`, { method: 'POST' });
}
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
@@ -28,6 +36,9 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
app.use(
createLoginRouter({
services: {
accountAuth: {
ensureFullyAuthenticated: async () => state === 'authenticated',
},
browser: {
isSessionValid: async () => state === 'authenticated',
getSessionState: () => state,
@@ -39,6 +50,20 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
return app;
}
function appWithPartialAuthentication() {
const app = express();
app.use(createLoginRouter({
services: {
accountAuth: { ensureFullyAuthenticated: async () => false },
browser: {
getSessionState: () => 'authenticated',
},
},
logger: { error: () => undefined },
} as never));
return app;
}
describe('Reddit session status', () => {
it('reports when Reddit is unreachable instead of presenting it as logged out', async () => {
const response = await request(appWithBrowser('unreachable'), '/login/status');
@@ -60,6 +85,16 @@ describe('Reddit session status', () => {
});
});
it('does not report authenticated when only the Reddit browser session is valid', async () => {
const response = await request(appWithPartialAuthentication(), '/login/status');
expect(await response.json()).toEqual({
ready: true,
loggedIn: false,
status: 'login-required',
});
});
it('returns the configured public noVNC URL', async () => {
process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk';
@@ -78,4 +113,27 @@ describe('Reddit session status', () => {
).toBe('unreachable');
expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required');
});
it('starts a native Chrome login handoff without controlling the attached page', async () => {
const requestNativeLogin = vi.fn();
const getPage = vi.fn();
const invalidate = vi.fn();
const app = express();
app.use(createLoginRouter({
services: {
accountAuth: { invalidate },
browser: { getPage },
nativeLogin: { request: requestNativeLogin },
},
logger: { info: vi.fn(), error: vi.fn() },
} as never));
const response = await post(app, '/login/start');
expect(response.status).toBe(202);
expect(await response.json()).toEqual({ ok: true, restarting: true });
expect(invalidate).toHaveBeenCalledWith('Native Reddit login requested');
expect(requestNativeLogin).toHaveBeenCalledOnce();
expect(getPage).not.toHaveBeenCalled();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { MediaSyncService } from '../src/services/media-sync.js';
import { createMockLogger } from './mockLogger.js';
describe('MediaSyncService account authentication gate', () => {
it('does not touch chat while the global account context is unauthenticated', async () => {
const chat = {
refreshTopChats: vi.fn(),
fetchAllMessages: vi.fn(),
};
const auth = { isFullyAuthenticated: vi.fn(() => false) };
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
auth,
);
await expect(service.triggerSync()).resolves.toBe(false);
expect(chat.refreshTopChats).not.toHaveBeenCalled();
expect(chat.fetchAllMessages).not.toHaveBeenCalled();
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createNativeLoginGate } from '../src/native-login-gate.js';
const servers: Array<ReturnType<typeof createNativeLoginGate>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => {
server.close(() => resolve());
})));
});
describe('native Reddit login gate', () => {
it('serves VNC guidance and completes only after the explicit handoff', async () => {
const onComplete = vi.fn();
const server = createNativeLoginGate({
publicVncUrl: 'https://red-vnc.example/vnc.html',
onComplete,
}).listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const page = await fetch(`http://127.0.0.1:${port}/`);
const html = await page.text();
expect(html).toContain('Chrome is running without automation');
expect(html).toContain('https://red-vnc.example/vnc.html');
expect(html).toContain("fetch('/complete', { method: 'POST' })");
expect(onComplete).not.toHaveBeenCalled();
const completed = await fetch(`http://127.0.0.1:${port}/complete`, { method: 'POST' });
expect(completed.status).toBe(202);
expect(await completed.json()).toEqual({ ok: true, restarting: true });
expect(onComplete).toHaveBeenCalledOnce();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { ResponseCheckerService } from '../src/services/response-checker.js';
import { createMockLogger } from './mockLogger.js';
describe('ResponseCheckerService account authentication gate', () => {
it('does not query repositories or navigate while unauthenticated', async () => {
const page = { goto: vi.fn() };
const messages = { getUncheckedMessages: vi.fn() };
const auth = { isFullyAuthenticated: vi.fn(() => false) };
const checker = new ResponseCheckerService(
page as never,
messages as never,
{} as never,
{} as never,
createMockLogger(),
auth,
);
await expect(checker.checkForResponses()).resolves.toEqual([]);
expect(messages.getUncheckedMessages).not.toHaveBeenCalled();
expect(page.goto).not.toHaveBeenCalled();
});
});
+21 -1
View File
@@ -1,9 +1,29 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Page } from 'playwright';
import { ResponseCheckerService } from '../src/services/response-checker.js';
import { ResponseCheckerService as BaseResponseCheckerService } from '../src/services/response-checker.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js';
import type { PostsRepository } from '../src/database/repositories.js';
import { createMockLogger } from '../tests/mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
class ResponseCheckerService extends BaseResponseCheckerService {
constructor(
page: Page,
messagesRepo: MessagesRepository,
successfulMessagesRepo: SuccessfulMessagesRepository,
postsRepo: PostsRepository,
logger: ReturnType<typeof createMockLogger>,
) {
super(
page,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger,
authenticatedAccountAuth,
);
}
}
function createMockPage(): Page {
return {
+5
View File
@@ -3,6 +3,11 @@ import type { Config } from '../src/config/index.js';
import { runMigrations } from '../src/database/client.js';
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js';
export const authenticatedAccountAuth = {
isFullyAuthenticated: () => true,
invalidate: () => undefined,
};
export function createTestDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('journal_mode = WAL');
+9 -3
View File
@@ -23,17 +23,23 @@ describe('VNC container entrypoint', () => {
expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot'));
});
it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => {
it('supports a native login phase with no DevTools before the controlled browser starts', () => {
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
expect(script).toContain('google-chrome');
expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"');
expect(script).toContain('start_native_login_chrome');
expect(script).toContain('https://www.reddit.com/login/');
expect(script).toContain('node dist/native-login-gate.js');
expect(script).toContain('NATIVE_LOGIN_EXIT_CODE');
expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"');
expect(script).toContain('export CHROME_CDP_URL=');
expect(script).not.toContain('--enable-automation');
expect(script.indexOf('starting Google Chrome')).toBeLessThan(
script.indexOf('launching bot'),
const nativeFunction = script.slice(
script.indexOf('start_native_login_chrome()'),
script.indexOf('start_controlled_chrome()'),
);
expect(nativeFunction).not.toContain('--remote-debugging');
});
it('installs the stable Google Chrome package rather than Chrome for Testing', () => {