Bind Reddit and Matrix session identities
This commit is contained in:
1 parent
34ae532cdd
commit
492efe0f8a
12 files changed
+234
-55
No files matched your search
@@ -13,3 +13,5 @@ This local Chrome extension exports `reddit.com` cookies and the current Reddit
|
||||
6. Delete the downloaded JSON file once the dashboard confirms that the session is authenticated.
|
||||
|
||||
The extension observes only requests to Reddit's Matrix homeservers, verifies the captured token with Matrix `whoami`, reads Reddit cookies, and saves the combined export locally. It does not send the dump to any server; you upload it directly to your RedditBot dashboard.
|
||||
|
||||
Before downloading, the extension independently resolves the current Reddit cookie identity and refuses the export unless its `t2` account ID matches Matrix `whoami`. Changing Reddit accounts clears the stored Matrix capture, so Reddit Chat must be reopened after every account switch.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "RedditBot Session Exporter",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Exports Reddit cookies and Reddit Chat credentials for a self-hosted RedditBot dashboard.",
|
||||
"permissions": ["cookies", "downloads", "storage", "webRequest"],
|
||||
"host_permissions": [
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createSessionExportDownload } from './session-export.js';
|
||||
import {
|
||||
assertMatchingSessionIdentity,
|
||||
createSessionExportDownload,
|
||||
} from './session-export.js';
|
||||
|
||||
const MATRIX_URLS = [
|
||||
'https://matrix.redditspace.com/*',
|
||||
@@ -27,6 +30,29 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
|
||||
['requestHeaders', 'extraHeaders'],
|
||||
);
|
||||
|
||||
chrome.cookies.onChanged.addListener(async ({ cookie }) => {
|
||||
if (!['reddit_session', 'token_v2'].includes(cookie.name)) return;
|
||||
await chrome.storage.local.remove('matrixCapture');
|
||||
await chrome.action.setBadgeText({ text: '' });
|
||||
await chrome.action.setTitle({
|
||||
title: 'Reddit account changed. Open Reddit Chat before exporting.',
|
||||
});
|
||||
});
|
||||
|
||||
async function getRedditIdentity() {
|
||||
const response = await fetch('https://www.reddit.com/api/me.json', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error('Reddit cookies did not authenticate. Sign in and try again.');
|
||||
const payload = await response.json();
|
||||
const account = payload?.data ?? payload;
|
||||
if (!account?.name || !account?.id) {
|
||||
throw new Error('Reddit did not return an authenticated account identity.');
|
||||
}
|
||||
return { username: account.name, accountId: account.id };
|
||||
}
|
||||
|
||||
async function showError(message) {
|
||||
await chrome.action.setBadgeText({ text: 'ERR' });
|
||||
await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' });
|
||||
@@ -48,6 +74,14 @@ chrome.action.onClicked.addListener(async () => {
|
||||
const identity = await whoami.json();
|
||||
if (!identity?.user_id) throw new Error('Reddit Chat did not return an account identity.');
|
||||
|
||||
const redditIdentity = await getRedditIdentity();
|
||||
try {
|
||||
assertMatchingSessionIdentity(redditIdentity, identity.user_id);
|
||||
} catch (error) {
|
||||
await chrome.storage.local.remove('matrixCapture');
|
||||
throw error;
|
||||
}
|
||||
|
||||
await chrome.downloads.download({
|
||||
...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }),
|
||||
saveAs: true,
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
const REDDITBOT_SESSION_FILENAME = 'redditbot-session.json';
|
||||
|
||||
export function normalizeRedditAccountId(accountId) {
|
||||
const trimmed = String(accountId ?? '').trim();
|
||||
const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`;
|
||||
if (!/^t2_[a-z0-9]+$/i.test(normalized)) {
|
||||
throw new Error('Reddit returned an invalid account ID.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function assertMatchingSessionIdentity(redditIdentity, matrixUserId) {
|
||||
const accountId = normalizeRedditAccountId(redditIdentity?.accountId);
|
||||
const username = String(redditIdentity?.username ?? '').trim();
|
||||
if (!username) throw new Error('Reddit did not return an authenticated username.');
|
||||
const expectedMatrixUserId = `@${accountId}:reddit.com`;
|
||||
if (matrixUserId !== expectedMatrixUserId) {
|
||||
throw new Error(
|
||||
`Reddit is logged in as u/${username} (${accountId}), but Chat is authenticated as ${matrixUserId}. Reload Reddit Chat after switching accounts, then export again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRedditDomain(domain) {
|
||||
const normalised = domain.toLowerCase().replace(/^\./, '');
|
||||
return normalised === 'reddit.com' || normalised.endsWith('.reddit.com');
|
||||
|
||||
+12
-12
@@ -37,7 +37,7 @@ import { RateLimiter } from './utils/rate-limiter.js';
|
||||
import { WebServer } from './web/server.js';
|
||||
import { reportReply } from './web/event-reporting.js';
|
||||
import { RedditSessionImporter } from './services/reddit-session-importer.js';
|
||||
import { AccountAuthContext } from './services/account-auth-context.js';
|
||||
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
|
||||
import { ConfigStore } from './config/persistence.js';
|
||||
|
||||
export interface AppContainer {
|
||||
@@ -164,17 +164,17 @@ export async function bootstrap(
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
accountAuth.setAuthenticator(() => authenticateCurrentAccount({
|
||||
getRedditIdentity: () => browserClient.getAuthenticatedIdentity(),
|
||||
refreshMatrixCredentials: async () => {
|
||||
try {
|
||||
const userId = await activeMatrixClient.whoami();
|
||||
return userId ? { userId } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated();
|
||||
if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified');
|
||||
broadcaster.dashboardEvents.redditSession({
|
||||
|
||||
+27
-10
@@ -3,6 +3,18 @@ import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import type { RedditAccountIdentity } from '../services/account-auth-context.js';
|
||||
|
||||
const RedditAccountSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
id: z.string().trim().min(1),
|
||||
}).passthrough();
|
||||
|
||||
const RedditIdentityResponseSchema = z.union([
|
||||
RedditAccountSchema,
|
||||
z.object({ data: RedditAccountSchema }).passthrough().transform(({ data }) => data),
|
||||
]);
|
||||
|
||||
export interface BrowserRuntime {
|
||||
launchPersistentContext(
|
||||
@@ -112,6 +124,11 @@ export class BrowserClient {
|
||||
|
||||
/** Returns the account name from Reddit's authenticated identity endpoint. */
|
||||
async getAuthenticatedUsername(): Promise<string | null> {
|
||||
return (await this.getAuthenticatedIdentity())?.username ?? null;
|
||||
}
|
||||
|
||||
/** Returns the username and stable t2 account ID for identity binding. */
|
||||
async getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null> {
|
||||
try {
|
||||
return await this.getRedditIdentity(await this.getPage());
|
||||
} catch {
|
||||
@@ -168,24 +185,24 @@ export class BrowserClient {
|
||||
return (await this.getRedditIdentity(page)) !== null;
|
||||
}
|
||||
|
||||
private async getRedditIdentity(page: Page): Promise<string | null> {
|
||||
private async getRedditIdentity(page: Page): Promise<RedditAccountIdentity | null> {
|
||||
try {
|
||||
return await page.evaluate(async () => {
|
||||
const payload: unknown = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
const data: unknown = await res.json();
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const account = 'data' in data && data.data && typeof data.data === 'object'
|
||||
? data.data
|
||||
: data;
|
||||
if (!('name' in account) || typeof account.name !== 'string') return null;
|
||||
const username = account.name.trim();
|
||||
return username || null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const parsed = RedditIdentityResponseSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
const rawAccountId = parsed.data.id;
|
||||
return {
|
||||
username: parsed.data.name,
|
||||
accountId: rawAccountId.startsWith('t2_') ? rawAccountId : `t2_${rawAccountId}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,18 @@ export type AccountAuthStatus =
|
||||
| 'authenticated';
|
||||
|
||||
export interface AuthenticatedAccount {
|
||||
/** Reddit account fullname, e.g. `t2_abc123`. */
|
||||
accountId: string;
|
||||
redditUsername: string;
|
||||
matrixUserId: string;
|
||||
}
|
||||
|
||||
export interface RedditAccountIdentity {
|
||||
username: string;
|
||||
/** Reddit's account ID, with or without the `t2_` prefix. */
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export interface AccountAuthSnapshot {
|
||||
status: AccountAuthStatus;
|
||||
accountId: string | null;
|
||||
@@ -34,21 +41,43 @@ export interface AccountAuthContextOptions {
|
||||
}
|
||||
|
||||
export interface CurrentAccountAuthentication {
|
||||
getRedditUsername(): Promise<string | null>;
|
||||
getRedditIdentity(): Promise<RedditAccountIdentity | null>;
|
||||
refreshMatrixCredentials(): Promise<{ userId: string } | null>;
|
||||
}
|
||||
|
||||
export function normalizeRedditAccountId(accountId: string): string {
|
||||
const trimmed = accountId.trim();
|
||||
const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`;
|
||||
if (!/^t2_[a-z0-9]+$/i.test(normalized)) {
|
||||
throw new Error('Reddit returned an invalid account ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function expectedMatrixUserId(accountId: string): string {
|
||||
return `@${normalizeRedditAccountId(accountId)}:reddit.com`;
|
||||
}
|
||||
|
||||
export function assertMatchingAccountIdentity(account: AuthenticatedAccount): void {
|
||||
const expected = expectedMatrixUserId(account.accountId);
|
||||
if (account.matrixUserId !== expected) {
|
||||
throw new Error(
|
||||
`Reddit account u/${account.redditUsername} (${normalizeRedditAccountId(account.accountId)}) does not match Matrix account ${account.matrixUserId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateCurrentAccount(
|
||||
authentication: CurrentAccountAuthentication,
|
||||
): Promise<AuthenticatedAccount | null> {
|
||||
const redditUsername = await authentication.getRedditUsername();
|
||||
if (!redditUsername) return null;
|
||||
const redditIdentity = await authentication.getRedditIdentity();
|
||||
if (!redditIdentity) return null;
|
||||
|
||||
const credentials = await authentication.refreshMatrixCredentials();
|
||||
if (!credentials?.userId) return null;
|
||||
return {
|
||||
accountId: credentials.userId,
|
||||
redditUsername,
|
||||
accountId: normalizeRedditAccountId(redditIdentity.accountId),
|
||||
redditUsername: redditIdentity.username,
|
||||
matrixUserId: credentials.userId,
|
||||
};
|
||||
}
|
||||
@@ -104,15 +133,15 @@ export class AccountAuthContext implements AccountAuthGate {
|
||||
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');
|
||||
}
|
||||
assertMatchingAccountIdentity(account);
|
||||
|
||||
const changedAccount = this.snapshot.accountId !== account.accountId;
|
||||
const accountId = normalizeRedditAccountId(account.accountId);
|
||||
|
||||
const changedAccount = this.snapshot.accountId !== accountId;
|
||||
const wasAuthenticated = this.isFullyAuthenticated();
|
||||
this.snapshot = {
|
||||
status: 'authenticated',
|
||||
accountId: account.accountId,
|
||||
accountId,
|
||||
redditUsername: account.redditUsername,
|
||||
matrixUserId: account.matrixUserId,
|
||||
reason: null,
|
||||
|
||||
@@ -2,11 +2,16 @@ import type { Cookie } from 'playwright';
|
||||
import type { MatrixClient } from './chat/matrix/client.js';
|
||||
import type { MatrixCredentials } from './chat/matrix/types.js';
|
||||
import type { AccountAuthContext } from './account-auth-context.js';
|
||||
import {
|
||||
assertMatchingAccountIdentity,
|
||||
normalizeRedditAccountId,
|
||||
type RedditAccountIdentity,
|
||||
} from './account-auth-context.js';
|
||||
|
||||
interface BrowserSessionTarget {
|
||||
importRedditCookies(cookies: Cookie[]): Promise<void>;
|
||||
isSessionValid(): Promise<boolean>;
|
||||
getAuthenticatedUsername(): Promise<string | null>;
|
||||
getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null>;
|
||||
}
|
||||
|
||||
interface ChatSessionTarget {
|
||||
@@ -41,17 +46,29 @@ export class RedditSessionImporter {
|
||||
}
|
||||
|
||||
await this.browser.importRedditCookies(cookies);
|
||||
if (!(await this.browser.isSessionValid())) {
|
||||
throw new Error('Imported cookies did not authenticate with Reddit');
|
||||
let redditIdentity: RedditAccountIdentity;
|
||||
try {
|
||||
if (!(await this.browser.isSessionValid())) {
|
||||
throw new Error('Imported cookies did not authenticate with Reddit');
|
||||
}
|
||||
const identity = await this.browser.getAuthenticatedIdentity();
|
||||
if (!identity) throw new Error('Reddit did not return an authenticated account identity');
|
||||
redditIdentity = identity;
|
||||
assertMatchingAccountIdentity({
|
||||
accountId: identity.accountId,
|
||||
redditUsername: identity.username,
|
||||
matrixUserId: userId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.accountAuth.invalidate(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
const redditUsername = await this.browser.getAuthenticatedUsername();
|
||||
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
|
||||
|
||||
this.saveCredentials(credentials);
|
||||
this.chat.setMatrixClient(matrixClient);
|
||||
this.accountAuth.markAuthenticated({
|
||||
accountId: userId,
|
||||
redditUsername,
|
||||
accountId: normalizeRedditAccountId(redditIdentity.accountId),
|
||||
redditUsername: redditIdentity.username,
|
||||
matrixUserId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('AccountAuthContext', () => {
|
||||
const refreshMatrixCredentials = vi.fn();
|
||||
|
||||
await expect(authenticateCurrentAccount({
|
||||
getRedditUsername: vi.fn().mockResolvedValue(null),
|
||||
getRedditIdentity: vi.fn().mockResolvedValue(null),
|
||||
refreshMatrixCredentials,
|
||||
})).resolves.toBeNull();
|
||||
|
||||
@@ -20,7 +20,7 @@ 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',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -34,7 +34,7 @@ describe('AccountAuthContext', () => {
|
||||
expect(auth.isFullyAuthenticated()).toBe(true);
|
||||
expect(auth.getSnapshot()).toMatchObject({
|
||||
status: 'authenticated',
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('AccountAuthContext', () => {
|
||||
const second = auth.ensureFullyAuthenticated();
|
||||
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
|
||||
resolveAuth?.({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe('AccountAuthContext', () => {
|
||||
it('invalidates the whole account context when either login layer expires', async () => {
|
||||
const auth = new AccountAuthContext(createMockLogger());
|
||||
auth.markAuthenticated({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe('AccountAuthContext', () => {
|
||||
now: () => now,
|
||||
});
|
||||
auth.markAuthenticated({
|
||||
accountId: '@t2_account:reddit.com',
|
||||
accountId: 't2_account',
|
||||
redditUsername: 'account',
|
||||
matrixUserId: '@t2_account:reddit.com',
|
||||
});
|
||||
@@ -115,4 +115,20 @@ describe('AccountAuthContext', () => {
|
||||
await auth.ensureFullyAuthenticated();
|
||||
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails closed when Reddit and Matrix belong to different accounts', async () => {
|
||||
const auth = new AccountAuthContext(createMockLogger());
|
||||
auth.setAuthenticator(vi.fn().mockResolvedValue({
|
||||
accountId: 't2_cookie',
|
||||
redditUsername: 'cookie-account',
|
||||
matrixUserId: '@t2_chat:reddit.com',
|
||||
}));
|
||||
|
||||
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
|
||||
expect(auth.getSnapshot()).toMatchObject({
|
||||
status: 'login-required',
|
||||
accountId: null,
|
||||
reason: expect.stringMatching(/does not match Matrix account/),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,10 +36,10 @@ describe('BrowserClient', () => {
|
||||
expect(JSON.stringify(logger)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('reads the username from Reddit\'s nested me.json account response', async () => {
|
||||
it('reads the username and stable account ID from Reddit\'s nested me.json response', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ kind: 't2', data: { name: 'bot-account' } }),
|
||||
json: async () => ({ kind: 't2', data: { name: 'bot-account', id: 'abc123' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const page = {
|
||||
@@ -50,6 +50,10 @@ describe('BrowserClient', () => {
|
||||
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
|
||||
|
||||
await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
|
||||
await expect(client.getAuthenticatedIdentity()).resolves.toEqual({
|
||||
username: 'bot-account',
|
||||
accountId: 't2_abc123',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://www.reddit.com/api/me.json',
|
||||
{ credentials: 'include' },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSessionExportDownload } from '../extensions/reddit-session-exporter/session-export.js';
|
||||
import {
|
||||
assertMatchingSessionIdentity,
|
||||
createSessionExportDownload,
|
||||
} from '../extensions/reddit-session-exporter/session-export.js';
|
||||
|
||||
describe('createSessionExportDownload', () => {
|
||||
it('creates a JSON data URL that Chrome can download from a service worker', () => {
|
||||
@@ -49,3 +52,19 @@ describe('createSessionExportDownload', () => {
|
||||
expect(() => createSessionExportDownload([], null)).toThrow(/Reddit Chat/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertMatchingSessionIdentity', () => {
|
||||
it('accepts a Matrix user bound to the current Reddit account ID', () => {
|
||||
expect(() => assertMatchingSessionIdentity(
|
||||
{ username: 'bot-account', accountId: 'bot' },
|
||||
'@t2_bot:reddit.com',
|
||||
)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a stale Matrix capture from another Reddit account', () => {
|
||||
expect(() => assertMatchingSessionIdentity(
|
||||
{ username: 'bot-account', accountId: 't2_bot' },
|
||||
'@t2_other:reddit.com',
|
||||
)).toThrow(/Reload Reddit Chat after switching accounts/);
|
||||
});
|
||||
});
|
||||
@@ -18,12 +18,12 @@ describe('RedditSessionImporter', () => {
|
||||
const browser = {
|
||||
importRedditCookies: vi.fn(),
|
||||
isSessionValid: vi.fn(async () => true),
|
||||
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
|
||||
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'bot-account', accountId: 't2_bot' })),
|
||||
};
|
||||
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
||||
const chat = { setMatrixClient: vi.fn() };
|
||||
const save = vi.fn();
|
||||
const accountAuth = { markAuthenticated: vi.fn() };
|
||||
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
|
||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
|
||||
|
||||
await importer.import(cookies, credentials);
|
||||
@@ -33,20 +33,40 @@ describe('RedditSessionImporter', () => {
|
||||
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
|
||||
expect(save).toHaveBeenCalledWith(credentials);
|
||||
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
|
||||
accountId: credentials.userId,
|
||||
accountId: 't2_bot',
|
||||
redditUsername: 'bot-account',
|
||||
matrixUserId: credentials.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a dump whose Reddit cookies and Matrix token belong to different accounts', async () => {
|
||||
const browser = {
|
||||
importRedditCookies: vi.fn(),
|
||||
isSessionValid: vi.fn(async () => true),
|
||||
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'other-account', accountId: 't2_other' })),
|
||||
};
|
||||
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
||||
const chat = { setMatrixClient: vi.fn() };
|
||||
const save = vi.fn();
|
||||
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
|
||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
|
||||
|
||||
await expect(importer.import(cookies, credentials)).rejects.toThrow(/does not match Matrix account/);
|
||||
|
||||
expect(accountAuth.invalidate).toHaveBeenCalledWith(expect.stringMatching(/does not match Matrix account/));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(chat.setMatrixClient).not.toHaveBeenCalled();
|
||||
expect(accountAuth.markAuthenticated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-Reddit Matrix homeservers without making a request', async () => {
|
||||
const createMatrix = vi.fn();
|
||||
const importer = new RedditSessionImporter(
|
||||
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
|
||||
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedIdentity: vi.fn() },
|
||||
{ setMatrixClient: vi.fn() } as never,
|
||||
createMatrix,
|
||||
vi.fn(),
|
||||
{ markAuthenticated: vi.fn() } as never,
|
||||
{ markAuthenticated: vi.fn(), invalidate: vi.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
|
||||
|
||||
Reference in new issue
Block a user