diff --git a/src/bootstrap.ts b/src/bootstrap.ts index f17135a..42db106 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -23,8 +23,6 @@ import { MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher, - extractMatrixCredentials, - loadCachedCredentials, type UsernameResolver, type MatrixCredentials, } from './services/chat/index.js'; @@ -33,6 +31,7 @@ import { MediaSyncService } from './services/media-sync.js'; import { RateLimiter } from './utils/rate-limiter.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; import { WebServer } from './web/server.js'; +import { AccountAuthContext, type AuthenticatedAccount } from './services/account-auth-context.js'; export interface AppContainer { config: Config; @@ -41,6 +40,7 @@ export interface AppContainer { browserClient: BrowserClient; messagePage: RedditMessagePage; + accountAuth: AccountAuthContext; postsRepo: PostsRepository; messagesRepo: MessagesRepository; @@ -97,6 +97,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise chatService.setMatrixClient(new MatrixClient(fresh, logger)), + (fresh) => { + credentialState.latest = fresh; + matrixClient.updateCredentials(fresh); + }, ); + accountAuth.setAuthenticator(async (): Promise => { + credentialState.latest = null; + await tokenRefresher.refresh(); + const redditUsername = await browserClient.getAuthenticatedUsername(); + const freshCredentials = credentialState.latest as MatrixCredentials | null; + if (!redditUsername || !freshCredentials?.userId) return null; + return { + accountId: freshCredentials.userId, + redditUsername, + matrixUserId: freshCredentials.userId, + }; + }); + + if (loggedIn) { + logger.info('Verifying full Reddit and Matrix account authentication...'); + await accountAuth.ensureFullyAuthenticated(); + } else { + accountAuth.invalidate('Interactive Reddit login is required'); + } + // 4. Username resolver — turns t2 IDs into real Reddit usernames so the // dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint. const usernameResolver: UsernameResolver = { @@ -228,7 +235,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise { - logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`); - // Resolve t2 IDs now that the full chat list is available. + let startupSyncAccountId: string | null = null; + let startupSyncInFlightAccountId: string | null = null; + const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise => { + if ( + startupSyncAccountId === account.accountId || + startupSyncInFlightAccountId === account.accountId + ) return; + startupSyncInFlightAccountId = account.accountId; + try { + const { kind, chats } = await chatService.runStartupSync(); await chatService.enrichUsernames(); - }) - .catch((err) => logger.error('[chat] startup sync failed:', err)); + if (accountAuth.isFullyAuthenticated()) { + startupSyncAccountId = account.accountId; + logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`); + } + } finally { + startupSyncInFlightAccountId = null; + } + }; + accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account)); + const authenticatedAccount = accountAuth.getSnapshot(); + if ( + authenticatedAccount.status === 'authenticated' && + authenticatedAccount.accountId && + authenticatedAccount.redditUsername && + authenticatedAccount.matrixUserId + ) { + void startAuthenticatedAccount({ + accountId: authenticatedAccount.accountId, + redditUsername: authenticatedAccount.redditUsername, + matrixUserId: authenticatedAccount.matrixUserId, + }).catch((err) => logger.error('[chat] startup sync failed:', err)); + } let webServer: WebServer | null = null; if (config.web.enabled) { @@ -291,6 +326,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.info('Runtime config updated via dashboard')); } @@ -312,6 +348,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise { + try { + const page = await this.getPage(); + return await this.readAuthenticatedUsername(page); + } catch { + return null; + } + } + async close(): Promise { if (this.connectedBrowser) { this.logger.info('Closing Google Chrome connection'); @@ -185,19 +194,23 @@ export class BrowserClient { } private async isLoggedInViaApi(page: Page): Promise { + return (await this.readAuthenticatedUsername(page)) !== null; + } + + private async readAuthenticatedUsername(page: Page): Promise { try { return await page.evaluate(async () => { try { const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' }); - if (!res.ok) return false; - const data = await res.json(); - return Boolean(data && data.name); + if (!res.ok) return null; + const data = await res.json() as { name?: unknown }; + return typeof data.name === 'string' && data.name ? data.name : null; } catch { - return false; + return null; } }); } catch { - return false; + return null; } } diff --git a/src/main-loop.ts b/src/main-loop.ts index d36d57f..41b6e6d 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -19,6 +19,7 @@ export async function runMainLoop(app: AppContainer): Promise { config, logger, browserClient, + accountAuth, postsRepo, messagesRepo, redditApi, @@ -65,7 +66,7 @@ export async function runMainLoop(app: AppContainer): Promise { const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000); const checkResponses = async (): Promise => { - if (isShuttingDown) return; + if (isShuttingDown || !accountAuth.isFullyAuthenticated()) return; try { logger.debug('Checking for responses to sent messages (Background Thread)...'); const responses = await responseChecker.checkForResponses(); @@ -92,11 +93,25 @@ export async function runMainLoop(app: AppContainer): Promise { responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS); let lastPausedLog = 0; + let lastAuthPausedLog = 0; let lastCooldownLog = 0; const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000; while (!isShuttingDown) { try { + if (!accountAuth.isFullyAuthenticated()) { + await accountAuth.ensureFullyAuthenticated(); + } + if (!accountAuth.isFullyAuthenticated()) { + if (Date.now() - lastAuthPausedLog > PAUSED_LOG_INTERVAL_MS) { + logger.info('Bot logic is locked until full Reddit and Matrix authentication is verified.'); + lastAuthPausedLog = Date.now(); + } + await sleep(PAUSE_POLL_MS); + continue; + } + lastAuthPausedLog = 0; + if (webServer && !webServer.isRunning()) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { logger.info('Bot is paused. Use the web dashboard to start.'); diff --git a/src/services/account-auth-context.ts b/src/services/account-auth-context.ts new file mode 100644 index 0000000..8384f0a --- /dev/null +++ b/src/services/account-auth-context.ts @@ -0,0 +1,157 @@ +import type { Logger } from '../utils/logger.js'; + +export type AccountAuthStatus = + | 'login-required' + | 'checking' + | 'authenticated'; + +export interface AuthenticatedAccount { + accountId: string; + redditUsername: string; + matrixUserId: string; +} + +export interface AccountAuthSnapshot { + status: AccountAuthStatus; + accountId: string | null; + redditUsername: string | null; + matrixUserId: string | null; + reason: string | null; +} + +export interface AccountAuthGate { + isFullyAuthenticated(): boolean; + invalidate(reason: string): void; +} + +export type AccountAuthenticator = () => Promise; +export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise; + +export interface AccountAuthContextOptions { + leaseMs?: number; + retryDelayMs?: number; + now?: () => number; +} + +export const denyAccountAuth: AccountAuthGate = { + isFullyAuthenticated: () => false, + invalidate: () => undefined, +}; + +export class AccountAuthContext implements AccountAuthGate { + private snapshot: AccountAuthSnapshot = { + status: 'login-required', + accountId: null, + redditUsername: null, + matrixUserId: null, + reason: 'Full Reddit and Matrix authentication has not been verified', + }; + private authenticator: AccountAuthenticator | null = null; + private authenticationInFlight: Promise | null = null; + private readonly listeners = new Set(); + private authenticatedUntil = 0; + private nextAuthenticationAttemptAt = 0; + private readonly leaseMs: number; + private readonly retryDelayMs: number; + private readonly now: () => number; + + constructor( + private readonly logger: Logger, + options: AccountAuthContextOptions = {}, + ) { + this.leaseMs = options.leaseMs ?? 30 * 60 * 1000; + this.retryDelayMs = options.retryDelayMs ?? 60 * 1000; + this.now = options.now ?? Date.now; + } + + setAuthenticator(authenticator: AccountAuthenticator): void { + this.authenticator = authenticator; + } + + isFullyAuthenticated(): boolean { + if (this.snapshot.status !== 'authenticated') return false; + if (this.now() < this.authenticatedUntil) return true; + this.invalidate('Full account authentication lease expired'); + return false; + } + + getSnapshot(): AccountAuthSnapshot { + this.isFullyAuthenticated(); + return { ...this.snapshot }; + } + + markAuthenticated(account: AuthenticatedAccount): void { + if (!account.accountId || !account.redditUsername || !account.matrixUserId) { + throw new Error('A fully authenticated account requires Reddit and Matrix identities'); + } + if (account.accountId !== account.matrixUserId) { + throw new Error('Authenticated account ID must match the Matrix user ID'); + } + + const changedAccount = this.snapshot.accountId !== account.accountId; + const wasAuthenticated = this.isFullyAuthenticated(); + this.snapshot = { + status: 'authenticated', + accountId: account.accountId, + redditUsername: account.redditUsername, + matrixUserId: account.matrixUserId, + reason: null, + }; + this.authenticatedUntil = this.now() + this.leaseMs; + this.nextAuthenticationAttemptAt = 0; + if (!wasAuthenticated || changedAccount) { + for (const listener of this.listeners) { + void Promise.resolve(listener(account)).catch((error) => { + this.logger.error('Authenticated account listener failed:', error); + }); + } + } + } + + invalidate(reason: string): void { + this.authenticatedUntil = 0; + this.snapshot = { + status: 'login-required', + accountId: null, + redditUsername: null, + matrixUserId: null, + reason, + }; + } + + onAuthenticated(listener: AccountAuthenticatedListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + ensureFullyAuthenticated(): Promise { + if (this.isFullyAuthenticated()) return Promise.resolve(true); + if (this.authenticationInFlight) return this.authenticationInFlight; + if (!this.authenticator) return Promise.resolve(false); + if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false); + + this.snapshot = { ...this.snapshot, status: 'checking', reason: null }; + this.authenticationInFlight = this.authenticate().finally(() => { + this.authenticationInFlight = null; + }); + return this.authenticationInFlight; + } + + private async authenticate(): Promise { + try { + const account = await this.authenticator!(); + if (!account) { + this.invalidate('Full Reddit and Matrix authentication is required'); + this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs; + return false; + } + this.markAuthenticated(account); + return true; + } catch (error) { + this.invalidate(error instanceof Error ? error.message : String(error)); + this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs; + this.logger.warn('Full account authentication failed:', error); + return false; + } + } +} diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 0506e67..292ddd5 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -12,6 +12,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown } export type { InvitationCooldown } from './types.js'; import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js'; +import type { AccountAuthGate } from '../account-auth-context.js'; const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000; const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; @@ -129,6 +130,7 @@ export class ChatService { cache?: ChatCache, private messagesRepo?: MessagesRepository, private redditUsersRepo?: RedditUsersRepository, + private accountAuth?: AccountAuthGate, ) { this.cache = cache ?? new ChatCache(logger); if (this.redditUsersRepo) { @@ -176,6 +178,11 @@ export class ChatService { * refresh itself fails, we propagate. */ private async withAuth(fn: () => Promise): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) { + throw new MatrixAuthenticationUnavailableError( + new Error('Full account authentication is required'), + ); + } try { return await fn(); } catch (err) { @@ -184,9 +191,15 @@ export class ChatService { try { await this.tokenRefresher.refresh(); } catch (refreshError) { + this.accountAuth?.invalidate('Matrix authentication could not be restored'); throw new MatrixAuthenticationUnavailableError(refreshError); } - return await fn(); + try { + return await fn(); + } catch (retryError) { + this.accountAuth?.invalidate('Matrix authentication was rejected after refresh'); + throw retryError; + } } throw err; } @@ -459,6 +472,7 @@ export class ChatService { * `chat.messages`. */ async loadConversationDetails(chat: ActiveChat): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) return; return this.loadDetails(chat); } @@ -468,6 +482,7 @@ export class ChatService { * a raw Matrix room ID like `!abc:reddit.com`. */ async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) return false; const active = this.getInvitationCooldown(); if (active) { const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); @@ -508,6 +523,7 @@ export class ChatService { * line. Returns the new chat (or null on failure). */ async startConversation(username: string, message: string): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) return null; const active = this.getInvitationCooldown(); if (active) { const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); @@ -571,7 +587,7 @@ export class ChatService { * old Playwright sender; always returns true. */ async verifyMessageSent(_recipient: string, _messageText: string): Promise { - return true; + return this.accountAuth?.isFullyAuthenticated() ?? false; } /** @@ -1026,6 +1042,10 @@ export class ChatService { * cache. Best-effort; safe to await or fire-and-forget. */ async enrichUsernames(): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) { + this.logger.debug('[chat] username enrichment skipped until full account authentication'); + return; + } if (!this.usernameResolver) return; let dirty = false; diff --git a/src/services/media-sync.ts b/src/services/media-sync.ts index 566e663..62e736a 100644 --- a/src/services/media-sync.ts +++ b/src/services/media-sync.ts @@ -1,5 +1,6 @@ import type { Logger } from '../utils/logger.js'; import type { ChatService } from './chat.js'; +import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js'; export interface MediaSyncStatus { isSyncing: boolean; @@ -22,7 +23,8 @@ export class MediaSyncService { constructor( private chatService: ChatService, private logger: Logger, - intervalMs: number = 30 * 60 * 1000 // default 30 minutes + intervalMs: number = 30 * 60 * 1000, // default 30 minutes + private accountAuth: AccountAuthGate = denyAccountAuth, ) { this.intervalMs = intervalMs; } @@ -64,6 +66,10 @@ export class MediaSyncService { * Returns immediately if a sync is already in progress. */ async triggerSync(full = false): Promise { + if (!this.accountAuth.isFullyAuthenticated()) { + this.logger.warn('Media sync: blocked until the account is fully authenticated'); + return false; + } if (this.syncing) { this.logger.info('Media sync: sync already in progress, skipping trigger'); return false; @@ -86,6 +92,10 @@ export class MediaSyncService { } private async runSync(full = false): Promise { + if (!this.accountAuth.isFullyAuthenticated()) { + this.logger.debug('Media sync: skipped because the account is not fully authenticated'); + return; + } if (this.syncing) return; this.syncing = true; this.lastError = null; diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts index c26f969..2a11385 100644 --- a/src/services/response-checker.ts +++ b/src/services/response-checker.ts @@ -2,6 +2,7 @@ import type { Page } from 'playwright'; import type { Logger } from '../utils/logger.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js'; import type { PostsRepository } from '../database/repositories.js'; +import type { AccountAuthGate } from './account-auth-context.js'; export interface ResponseCheckResult { messageId: number; @@ -16,11 +17,16 @@ export class ResponseCheckerService { private messagesRepo: MessagesRepository, private successfulMessagesRepo: SuccessfulMessagesRepository, private postsRepo: PostsRepository, - private logger: Logger + private logger: Logger, + private accountAuth?: AccountAuthGate, ) { } async checkForResponses(): Promise { const results: ResponseCheckResult[] = []; + if (!this.accountAuth?.isFullyAuthenticated()) { + this.logger.debug('Response check skipped until full account authentication'); + return results; + } // Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours) const minAgeMs = 60 * 60 * 1000; // 1 hour diff --git a/src/web/context.ts b/src/web/context.ts index 0eaaf4c..5efca2b 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -13,6 +13,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js'; import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; +import type { AccountAuthContext } from '../services/account-auth-context.js'; import type { ActivityLog } from './activity-log.js'; import type { LogBroadcaster } from './log-broadcaster.js'; import type { RuntimeConfig } from './types.js'; @@ -40,6 +41,7 @@ export interface WebContext { ai?: OpenRouterClient; strategySelector?: StrategySelector; browser?: BrowserClient; + accountAuth?: AccountAuthContext; }; /** Bot pause/resume toggle read by the main loop. */ diff --git a/src/web/routes/control.ts b/src/web/routes/control.ts index 48511b0..d69eed0 100644 --- a/src/web/routes/control.ts +++ b/src/web/routes/control.ts @@ -5,6 +5,11 @@ export function createControlRouter(ctx: WebContext): Router { const router = Router(); router.post('/start', (_req, res) => { + if (!ctx.services.accountAuth?.isFullyAuthenticated()) { + ctx.logger.warn('Bot start blocked until full account authentication'); + res.status(409).json({ success: false, running: false, authenticated: false }); + return; + } ctx.setBotRunning(true); ctx.activity.push('sent', 'Bot started by user'); ctx.logger.info('Bot started via web UI'); diff --git a/src/web/routes/login.ts b/src/web/routes/login.ts index 46fc7b0..120b93c 100644 --- a/src/web/routes/login.ts +++ b/src/web/routes/login.ts @@ -18,13 +18,23 @@ export function createLoginRouter(ctx: WebContext): Router { router.get('/login/status', async (_req, res) => { const browser = ctx.services.browser; - if (!browser) { + const accountAuth = ctx.services.accountAuth; + if (!browser || !accountAuth) { res.status(503).json({ ready: false, error: 'browser not initialised' }); return; } try { - const loggedIn = await browser.isSessionValid(); - res.json({ ready: true, loggedIn, status: browser.getSessionState() }); + const loggedIn = await accountAuth.ensureFullyAuthenticated(); + const browserState = browser.getSessionState(); + res.json({ + ready: true, + loggedIn, + status: loggedIn + ? 'authenticated' + : browserState === 'unreachable' + ? 'unreachable' + : 'login-required', + }); } catch (error) { ctx.logger.error('Failed to check login status:', error); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); @@ -40,6 +50,7 @@ export function createLoginRouter(ctx: WebContext): Router { return; } try { + ctx.services.accountAuth?.invalidate('Interactive Reddit login requested'); const page = await browser.getPage(); await page.bringToFront().catch(() => undefined); await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); diff --git a/src/web/server.ts b/src/web/server.ts index 046bd8b..cbbd9dd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -17,6 +17,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js'; import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; +import type { AccountAuthContext } from '../services/account-auth-context.js'; import { ActivityLog } from './activity-log.js'; import { LogBroadcaster } from './log-broadcaster.js'; import type { WebContext } from './context.js'; @@ -175,6 +176,10 @@ export class WebServer { this.services.browser = browserClient; } + setAccountAuthContext(accountAuth: AccountAuthContext): void { + this.services.accountAuth = accountAuth; + } + logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { this.activity.push(type, message, recipient, imageUrl); } diff --git a/tests/account-auth-context.test.ts b/tests/account-auth-context.test.ts new file mode 100644 index 0000000..4ef3399 --- /dev/null +++ b/tests/account-auth-context.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AccountAuthContext } from '../src/services/account-auth-context.js'; +import { createMockLogger } from './mockLogger.js'; + +describe('AccountAuthContext', () => { + 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); + }); +}); diff --git a/tests/chat-service-auth-gate.test.ts b/tests/chat-service-auth-gate.test.ts new file mode 100644 index 0000000..fc14b15 --- /dev/null +++ b/tests/chat-service-auth-gate.test.ts @@ -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(); + }); +}); diff --git a/tests/chat-service-cooldown.test.ts b/tests/chat-service-cooldown.test.ts index 26d6c2b..b349446 100644 --- a/tests/chat-service-cooldown.test.ts +++ b/tests/chat-service-cooldown.test.ts @@ -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); diff --git a/tests/chat-service-delta-media.test.ts b/tests/chat-service-delta-media.test.ts index 00d2506..6e347d3 100644 --- a/tests/chat-service-delta-media.test.ts +++ b/tests/chat-service-delta-media.test.ts @@ -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(); }); }); diff --git a/tests/chat-service-username-persist.test.ts b/tests/chat-service-username-persist.test.ts index afefccb..5655204 100644 --- a/tests/chat-service-username-persist.test.ts +++ b/tests/chat-service-username-persist.test.ts @@ -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(); diff --git a/tests/control-auth.test.ts b/tests/control-auth.test.ts new file mode 100644 index 0000000..996a4bd --- /dev/null +++ b/tests/control-auth.test.ts @@ -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> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map( + (server) => new Promise((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((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(); + }); +}); diff --git a/tests/login-status.test.ts b/tests/login-status.test.ts index cd441bd..762146e 100644 --- a/tests/login-status.test.ts +++ b/tests/login-status.test.ts @@ -28,6 +28,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 +42,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 +77,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'; diff --git a/tests/media-sync-auth.test.ts b/tests/media-sync-auth.test.ts new file mode 100644 index 0000000..daf8108 --- /dev/null +++ b/tests/media-sync-auth.test.ts @@ -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(); + }); +}); diff --git a/tests/response-checker-auth.test.ts b/tests/response-checker-auth.test.ts new file mode 100644 index 0000000..79f8e62 --- /dev/null +++ b/tests/response-checker-auth.test.ts @@ -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(); + }); +}); diff --git a/tests/response-checker.test.ts b/tests/response-checker.test.ts index a83d6ca..043f82a 100644 --- a/tests/response-checker.test.ts +++ b/tests/response-checker.test.ts @@ -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, + ) { + super( + page, + messagesRepo, + successfulMessagesRepo, + postsRepo, + logger, + authenticatedAccountAuth, + ); + } +} function createMockPage(): Page { return { diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 72de47f..1c9132c 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -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');