Gate bot logic on full account authentication
This commit is contained in:
1 parent
4db6de8bf5
commit
2fd92bf82c
22 files changed
+635
-65
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user