fix(auth): unify imported sessions with account gating

This commit is contained in:
jjbrunton committed 2026-08-08 16:53:55 +01:00
1 parent 654bbda457
commit ea92a1a92b
15 files changed
+123 -49

No files matched your search

@@ -16,11 +16,15 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
return ( return (
<article className="media-card"> <article className="media-card">
<div className="media-preview"> <div className="media-preview">
{isRevealed ? ( <button
<button aria-label={`View media from ${item.sender} larger`} className="media-preview-button" onClick={onOpenPreview} type="button"> aria-label={`View media from ${item.sender} larger`}
<img alt={`Media from ${item.sender}`} className="media-image" loading="lazy" src={item.urlPath} /> className="media-preview-button"
</button> disabled={!isRevealed}
) : <img alt={`Media from ${item.sender}`} className="media-image is-blurred" loading="lazy" src={item.urlPath} />} onClick={onOpenPreview}
type="button"
>
<img alt={`Media from ${item.sender}`} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
</button>
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null} {!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
</div> </div>
<div className="media-card-body"> <div className="media-card-body">
+37 -14
View File
@@ -37,6 +37,7 @@ import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js'; import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js'; import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js'; import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext } from './services/account-auth-context.js';
export interface AppContainer { export interface AppContainer {
config: Config; config: Config;
@@ -44,6 +45,7 @@ export interface AppContainer {
db: Database.Database; db: Database.Database;
browserClient: BrowserClient; browserClient: BrowserClient;
accountAuth: AccountAuthContext;
postsRepo: PostsRepository; postsRepo: PostsRepository;
messagesRepo: MessagesRepository; messagesRepo: MessagesRepository;
@@ -102,23 +104,17 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// Browser // Browser
const browserClient = new BrowserClient(config, logger); const browserClient = new BrowserClient(config, logger);
const accountAuth = new AccountAuthContext(logger);
await browserClient.initialize(); await browserClient.initialize();
// The extension import is the only session acquisition path. A missing // The extension import is the only session acquisition path. A missing
// session is recoverable: the dashboard remains available while all // session is recoverable: the dashboard remains available while all
// account-bound work waits for a verified dump. // account-bound work waits for a verified dump.
logger.info('Verifying Reddit login status...'); logger.info('Verifying Reddit login status...');
const loggedIn = await browserClient.isSessionValid(); const redditLoggedIn = await browserClient.isSessionValid();
if (!loggedIn) { if (!redditLoggedIn) {
logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.'); logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.');
} }
if (loggedIn) {
logger.info('Reddit login verified');
}
broadcaster.dashboardEvents.redditSession({
state: loggedIn ? 'authenticated' : 'login-required',
username: loggedIn ? await browserClient.getAuthenticatedUsername() ?? undefined : undefined,
});
// Reddit API client (shares browser context for auth) // Reddit API client (shares browser context for auth)
const redditApi = new RedditApiClient(config, logger); const redditApi = new RedditApiClient(config, logger);
@@ -143,6 +139,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
successfulMessagesRepo, successfulMessagesRepo,
postsRepo, postsRepo,
logger, logger,
accountAuth,
conversationOutcomesRepo, conversationOutcomesRepo,
); );
@@ -161,11 +158,28 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
capturedAt: Date.now(), capturedAt: Date.now(),
}; };
} }
const matrixClient = new MatrixClient(matrixCreds, logger); let activeMatrixClient = new MatrixClient(matrixCreds, logger);
accountAuth.setAuthenticator(async () => {
const redditUsername = await browserClient.getAuthenticatedUsername();
if (!redditUsername) return null;
try {
const matrixUserId = await activeMatrixClient.whoami();
if (!matrixUserId) return null;
return { accountId: matrixUserId, redditUsername, matrixUserId };
} catch {
return null;
}
});
const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated();
if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified');
broadcaster.dashboardEvents.redditSession({
state: fullyAuthenticated ? 'authenticated' : 'login-required',
username: fullyAuthenticated ? accountAuth.getSnapshot().redditUsername ?? undefined : undefined,
});
// 2. Build the media downloader + service on top of the Matrix client so // 2. Build the media downloader + service on top of the Matrix client so
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline. // `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger); const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger); const mediaService = new MediaService(mediaDownloader, logger);
// A 401 waits for a new extension dump instead of opening a browser login // A 401 waits for a new extension dump instead of opening a browser login
@@ -204,7 +218,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
}; };
const chatService = new ChatService( const chatService = new ChatService(
matrixClient, activeMatrixClient,
logger, logger,
successfulMessagesRepo, successfulMessagesRepo,
mediaService, mediaService,
@@ -213,9 +227,10 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
undefined, undefined,
messagesRepo, messagesRepo,
redditUsersRepo, redditUsersRepo,
accountAuth,
); );
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs); const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
const rateLimiter = new RateLimiter(config, logger, undefined, { const rateLimiter = new RateLimiter(config, logger, undefined, {
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since), sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
}); });
@@ -225,9 +240,15 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const sessionImporter = new RedditSessionImporter( const sessionImporter = new RedditSessionImporter(
browserClient, browserClient,
chatService, {
setMatrixClient(client) {
activeMatrixClient = client;
chatService.setMatrixClient(client);
},
},
(credentials) => new MatrixClient(credentials, logger), (credentials) => new MatrixClient(credentials, logger),
saveCredentials, saveCredentials,
accountAuth,
); );
let webServer: WebServer | null = null; let webServer: WebServer | null = null;
@@ -296,6 +317,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setStrategySelector(strategySelector); webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient); webServer.setBrowserClient(browserClient);
webServer.setSessionImporter(sessionImporter); webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth);
webServer.setConversationStateRepository(conversationStateRepo); webServer.setConversationStateRepository(conversationStateRepo);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
} }
@@ -316,6 +338,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
logger, logger,
db, db,
browserClient, browserClient,
accountAuth,
postsRepo, postsRepo,
messagesRepo, messagesRepo,
successfulMessagesRepo, successfulMessagesRepo,
+4 -1
View File
@@ -77,6 +77,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
config, config,
logger, logger,
browserClient, browserClient,
accountAuth,
postsRepo, postsRepo,
messagesRepo, messagesRepo,
redditApi, redditApi,
@@ -177,7 +178,9 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
while (!isShuttingDown) { while (!isShuttingDown) {
try { try {
if (!sessionRuntime.reconcile(browserClient.getSessionState())) { const authenticated = accountAuth.isFullyAuthenticated()
|| await accountAuth.ensureFullyAuthenticated();
if (!sessionRuntime.reconcile(authenticated)) {
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
logger.info('Reddit is logged out. Waiting for a session dump from the extension.'); logger.info('Reddit is logged out. Waiting for a session dump from the extension.');
lastPausedLog = Date.now(); lastPausedLog = Date.now();
+5 -7
View File
@@ -19,9 +19,8 @@ const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
export interface TokenRefresher { export interface TokenRefresher {
/** /**
* Re-extract a fresh Matrix bearer token (e.g. by re-running the browser * Requests replacement Matrix credentials. Extension-only authentication
* sniff against the persistent session) and apply it to the underlying * deliberately rejects this until the user imports a fresh dump.
* client. Resolves when the client is ready to be retried.
*/ */
refresh(): Promise<void>; refresh(): Promise<void>;
} }
@@ -175,9 +174,8 @@ export class ChatService {
} }
/** /**
* Run a Matrix call; if it 401/403s, re-extract the bearer via the * Run a Matrix call; if it 401/403s, invalidate the shared account gate.
* configured `TokenRefresher` and retry once. Single retry only — if the * The configured refresher reports that a new extension dump is required.
* refresh itself fails, we propagate.
*/ */
private async withAuth<T>(fn: () => Promise<T>): Promise<T> { private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
if (!this.accountAuth?.isFullyAuthenticated()) { if (!this.accountAuth?.isFullyAuthenticated()) {
@@ -189,7 +187,7 @@ export class ChatService {
return await fn(); return await fn();
} catch (err) { } catch (err) {
if (err instanceof MatrixAuthError && this.tokenRefresher) { if (err instanceof MatrixAuthError && this.tokenRefresher) {
this.logger.warn('[chat] Matrix token rejected — refreshing via browser session'); this.logger.warn('[chat] Matrix token rejected — waiting for a new extension dump');
try { try {
await this.tokenRefresher.refresh(); await this.tokenRefresher.refresh();
} catch (refreshError) { } catch (refreshError) {
+10
View File
@@ -1,10 +1,12 @@
import type { Cookie } from 'playwright'; import type { Cookie } from 'playwright';
import type { MatrixClient } from './chat/matrix/client.js'; import type { MatrixClient } from './chat/matrix/client.js';
import type { MatrixCredentials } from './chat/matrix/types.js'; import type { MatrixCredentials } from './chat/matrix/types.js';
import type { AccountAuthContext } from './account-auth-context.js';
interface BrowserSessionTarget { interface BrowserSessionTarget {
importRedditCookies(cookies: Cookie[]): Promise<void>; importRedditCookies(cookies: Cookie[]): Promise<void>;
isSessionValid(): Promise<boolean>; isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>;
} }
interface ChatSessionTarget { interface ChatSessionTarget {
@@ -20,6 +22,7 @@ export class RedditSessionImporter {
private readonly chat: ChatSessionTarget, private readonly chat: ChatSessionTarget,
private readonly createMatrixClient: MatrixClientFactory, private readonly createMatrixClient: MatrixClientFactory,
private readonly saveCredentials: CredentialStore, private readonly saveCredentials: CredentialStore,
private readonly accountAuth: AccountAuthContext,
) {} ) {}
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> { async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> {
@@ -41,8 +44,15 @@ export class RedditSessionImporter {
if (!(await this.browser.isSessionValid())) { if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit'); throw new Error('Imported cookies did not authenticate with Reddit');
} }
const redditUsername = await this.browser.getAuthenticatedUsername();
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
this.saveCredentials(credentials); this.saveCredentials(credentials);
this.chat.setMatrixClient(matrixClient); this.chat.setMatrixClient(matrixClient);
this.accountAuth.markAuthenticated({
accountId: userId,
redditUsername,
matrixUserId: userId,
});
} }
} }
+3
View File
@@ -3,6 +3,7 @@ import type { Logger } from '../utils/logger.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
import type { PostsRepository } from '../database/repositories.js'; import type { PostsRepository } from '../database/repositories.js';
import { classifyConversationOutcome } from './conversation-outcome.js'; import { classifyConversationOutcome } from './conversation-outcome.js';
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
export interface ResponseCheckResult { export interface ResponseCheckResult {
messageId: number; messageId: number;
@@ -18,11 +19,13 @@ export class ResponseCheckerService {
private successfulMessagesRepo: SuccessfulMessagesRepository, private successfulMessagesRepo: SuccessfulMessagesRepository,
private postsRepo: PostsRepository, private postsRepo: PostsRepository,
private logger: Logger, private logger: Logger,
private accountAuth: AccountAuthGate = denyAccountAuth,
private outcomesRepo?: ConversationOutcomesRepository, private outcomesRepo?: ConversationOutcomesRepository,
) { } ) { }
async checkForResponses(): Promise<ResponseCheckResult[]> { async checkForResponses(): Promise<ResponseCheckResult[]> {
const results: ResponseCheckResult[] = []; const results: ResponseCheckResult[] = [];
if (!this.accountAuth.isFullyAuthenticated()) return results;
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours) // Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
const minAgeMs = 60 * 60 * 1000; // 1 hour const minAgeMs = 60 * 60 * 1000; // 1 hour
+1 -4
View File
@@ -1,5 +1,3 @@
import type { RedditSessionState } from '../browser/client.js';
/** Starts and stops account-bound work as authentication changes. */ /** Starts and stops account-bound work as authentication changes. */
export class SessionBoundRuntime { export class SessionBoundRuntime {
private active = false; private active = false;
@@ -9,8 +7,7 @@ export class SessionBoundRuntime {
private readonly stop: () => void, private readonly stop: () => void,
) {} ) {}
reconcile(state: RedditSessionState): boolean { reconcile(authenticated: boolean): boolean {
const authenticated = state === 'authenticated';
if (authenticated && !this.active) { if (authenticated && !this.active) {
this.active = true; this.active = true;
this.start(); this.start();
+2
View File
@@ -18,6 +18,7 @@ import type { ActivityLog } from './activity-log.js';
import type { LogBroadcaster } from './log-broadcaster.js'; import type { LogBroadcaster } from './log-broadcaster.js';
import type { RuntimeConfig } from './types.js'; import type { RuntimeConfig } from './types.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js'; import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
/** Bag of everything the route modules might need. */ /** Bag of everything the route modules might need. */
export interface WebContext { export interface WebContext {
@@ -43,6 +44,7 @@ export interface WebContext {
strategySelector?: StrategySelector; strategySelector?: StrategySelector;
browser?: BrowserClient; browser?: BrowserClient;
sessionImporter?: RedditSessionImporter; sessionImporter?: RedditSessionImporter;
accountAuth?: AccountAuthContext;
conversationState?: ConversationStateRepository; conversationState?: ConversationStateRepository;
}; };
+19 -3
View File
@@ -52,9 +52,25 @@ export function createLoginRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const loggedIn = await browser.isSessionValid(); const accountAuth = ctx.services.accountAuth;
const username = loggedIn ? await browser.getAuthenticatedUsername() : null; const fullyAuthenticated = accountAuth
res.json({ ready: true, loggedIn, status: browser.getSessionState(), username }); ? await accountAuth.ensureFullyAuthenticated()
: await browser.isSessionValid();
if (fullyAuthenticated) {
const username = accountAuth?.getSnapshot().redditUsername
?? await browser.getAuthenticatedUsername();
res.json({ ready: true, loggedIn: true, status: 'authenticated', username });
return;
}
await browser.isSessionValid();
const browserState = browser.getSessionState();
res.json({
ready: true,
loggedIn: false,
status: browserState === 'unreachable' ? 'unreachable' : 'login-required',
username: null,
});
} catch (error) { } catch (error) {
ctx.logger.error('Failed to check login status:', error); ctx.logger.error('Failed to check login status:', error);
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
+5
View File
@@ -20,6 +20,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js'; import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js'; import type { BrowserClient } from '../browser/client.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js'; import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
import { ActivityLog } from './activity-log.js'; import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js'; import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js'; import type { WebContext } from './context.js';
@@ -205,6 +206,10 @@ export class WebServer {
this.services.sessionImporter = sessionImporter; this.services.sessionImporter = sessionImporter;
} }
setAccountAuth(accountAuth: AccountAuthContext): void {
this.services.accountAuth = accountAuth;
}
setConversationStateRepository(conversationState: ConversationStateRepository): void { setConversationStateRepository(conversationState: ConversationStateRepository): void {
this.services.conversationState = conversationState; this.services.conversationState = conversationState;
} }
+1 -1
View File
@@ -270,7 +270,7 @@ describe('settings and access', () => {
renderSettings('/settings?section=access'); renderSettings('/settings?section=access');
expect(await screen.findByText('Login required')).toBeInTheDocument(); expect(await screen.findByText('Login required')).toBeInTheDocument();
expect(screen.getByText(/waiting for a session dump/i)).toBeInTheDocument(); expect(screen.getByText(/waiting for a fresh session dump/i)).toBeInTheDocument();
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument(); expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
+4
View File
@@ -27,6 +27,10 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
app.use( app.use(
createLoginRouter({ createLoginRouter({
services: { services: {
accountAuth: {
ensureFullyAuthenticated: async () => state === 'authenticated',
getSnapshot: () => ({ redditUsername: state === 'authenticated' ? 'example-account' : null }),
},
browser: { browser: {
isSessionValid: async () => state === 'authenticated', isSessionValid: async () => state === 'authenticated',
getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null, getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null,
+14 -3
View File
@@ -15,11 +15,16 @@ const credentials = {
describe('RedditSessionImporter', () => { describe('RedditSessionImporter', () => {
it('verifies both parts of the dump before activating the account', async () => { it('verifies both parts of the dump before activating the account', async () => {
const browser = { importRedditCookies: vi.fn(), isSessionValid: vi.fn(async () => true) }; const browser = {
importRedditCookies: vi.fn(),
isSessionValid: vi.fn(async () => true),
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
};
const matrix = { whoami: vi.fn(async () => credentials.userId) }; const matrix = { whoami: vi.fn(async () => credentials.userId) };
const chat = { setMatrixClient: vi.fn() }; const chat = { setMatrixClient: vi.fn() };
const save = vi.fn(); const save = vi.fn();
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save); const accountAuth = { markAuthenticated: vi.fn() };
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
await importer.import(cookies, credentials); await importer.import(cookies, credentials);
@@ -27,15 +32,21 @@ describe('RedditSessionImporter', () => {
expect(browser.importRedditCookies).toHaveBeenCalledWith(cookies); expect(browser.importRedditCookies).toHaveBeenCalledWith(cookies);
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix); expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
expect(save).toHaveBeenCalledWith(credentials); expect(save).toHaveBeenCalledWith(credentials);
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
accountId: credentials.userId,
redditUsername: 'bot-account',
matrixUserId: credentials.userId,
});
}); });
it('rejects non-Reddit Matrix homeservers without making a request', async () => { it('rejects non-Reddit Matrix homeservers without making a request', async () => {
const createMatrix = vi.fn(); const createMatrix = vi.fn();
const importer = new RedditSessionImporter( const importer = new RedditSessionImporter(
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn() }, { importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
{ setMatrixClient: vi.fn() } as never, { setMatrixClient: vi.fn() } as never,
createMatrix, createMatrix,
vi.fn(), vi.fn(),
{ markAuthenticated: vi.fn() } as never,
); );
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' })) await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
+8 -11
View File
@@ -7,22 +7,19 @@ describe('SessionBoundRuntime', () => {
const stop = vi.fn(); const stop = vi.fn();
const runtime = new SessionBoundRuntime(start, stop); const runtime = new SessionBoundRuntime(start, stop);
expect(runtime.reconcile('login-required')).toBe(false); expect(runtime.reconcile(false)).toBe(false);
expect(start).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled();
expect(runtime.reconcile('authenticated')).toBe(true); expect(runtime.reconcile(true)).toBe(true);
expect(runtime.reconcile('authenticated')).toBe(true); expect(runtime.reconcile(true)).toBe(true);
expect(start).toHaveBeenCalledOnce(); expect(start).toHaveBeenCalledOnce();
expect(runtime.reconcile('login-required')).toBe(false); expect(runtime.reconcile(false)).toBe(false);
expect(stop).toHaveBeenCalledOnce(); expect(stop).toHaveBeenCalledOnce();
}); });
it.each(['unknown', 'login-required', 'unreachable'] as const)( it('treats incomplete authentication as waiting for an extension dump', () => {
'treats %s as waiting for an extension dump', const runtime = new SessionBoundRuntime(vi.fn(), vi.fn());
(state) => { expect(runtime.reconcile(false)).toBe(false);
const runtime = new SessionBoundRuntime(vi.fn(), vi.fn()); });
expect(runtime.reconcile(state)).toBe(false);
},
);
}); });
+1
View File
@@ -10,6 +10,7 @@ describe('control route realtime events', () => {
const bot = vi.fn(); const bot = vi.fn();
const context = { const context = {
config: { browser: { dryRun: true } }, config: { browser: { dryRun: true } },
services: { accountAuth: { isFullyAuthenticated: () => true } },
setBotRunning: (next: boolean) => { running = next; }, setBotRunning: (next: boolean) => { running = next; },
activity: { push: vi.fn() }, activity: { push: vi.fn() },
logger: { info: vi.fn() }, logger: { info: vi.fn() },