Expired Matrix bearers no longer pause an account until a new extension dump is uploaded. BrowserMatrixTokenRefresher loads chat.reddit.com in the account's persistent browser profile (whose Reddit cookies stay valid), lifts the fresh bearer off the first authenticated Matrix request, verifies it via whoami, swaps it into the live MatrixClient and persists it to the account row. Wired into the per-account runtime at boot verification, the auth-gate authenticator, and ChatService's 401 retry path. A dump is now only needed when the Reddit cookies themselves no longer authenticate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
9.6 KiB
TypeScript
200 lines
9.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
AccountContentRepository,
|
|
RedditAccountsRepository,
|
|
} from '../src/database/account-repositories.js';
|
|
import { LEGACY_UNASSIGNED_ACCOUNT_ID, runMigrations } from '../src/database/client.js';
|
|
import { importLegacyAccountContent } from '../src/database/legacy-content-import.js';
|
|
|
|
describe('multi-account database ownership', () => {
|
|
let db: Database.Database;
|
|
let accounts: RedditAccountsRepository;
|
|
let content: AccountContentRepository;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
db.pragma('foreign_keys = ON');
|
|
runMigrations(db);
|
|
accounts = new RedditAccountsRepository(db);
|
|
content = new AccountContentRepository(db);
|
|
});
|
|
|
|
afterEach(() => db.close());
|
|
|
|
function addAccount(accountId: string, username: string, now: number) {
|
|
return accounts.upsertVerified({
|
|
accountId,
|
|
redditUsername: username,
|
|
matrixUserId: `@${accountId}:reddit.com`,
|
|
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: `${username}-secret` }]),
|
|
matrixCredentialsJson: JSON.stringify({ accessToken: `${username}-matrix-secret` }),
|
|
now,
|
|
});
|
|
}
|
|
|
|
it('stores private verified session material without exposing it in public listings', () => {
|
|
const publicAccount = addAccount('t2_alice', 'alice-bot', 100);
|
|
|
|
expect(publicAccount).toMatchObject({
|
|
accountId: 't2_alice', redditUsername: 'alice-bot', status: 'verified', isActive: true,
|
|
createdAt: 100, updatedAt: 100, lastVerifiedAt: 100, lastImportedAt: 100,
|
|
});
|
|
expect(publicAccount).not.toHaveProperty('cookiesJson');
|
|
expect(publicAccount).not.toHaveProperty('matrixCredentialsJson');
|
|
expect(accounts.listPublic().find((item) => item.accountId === 't2_alice'))
|
|
.not.toHaveProperty('cookiesJson');
|
|
|
|
expect(accounts.get('t2_alice')).toMatchObject({
|
|
cookiesJson: expect.stringContaining('alice-bot-secret'),
|
|
matrixCredentialsJson: expect.stringContaining('alice-bot-matrix-secret'),
|
|
});
|
|
});
|
|
|
|
it('updates matrix credentials in place without touching cookies or status', () => {
|
|
addAccount('t2_alice', 'alice-bot', 100);
|
|
|
|
const refreshed = JSON.stringify({ accessToken: 'refreshed-matrix-secret' });
|
|
expect(accounts.updateMatrixCredentials('t2_alice', refreshed, 200)).toBe(true);
|
|
|
|
expect(accounts.get('t2_alice')).toMatchObject({
|
|
matrixCredentialsJson: refreshed,
|
|
cookiesJson: expect.stringContaining('alice-bot-secret'),
|
|
status: 'verified',
|
|
updatedAt: 200,
|
|
lastVerifiedAt: 200,
|
|
});
|
|
expect(accounts.updateMatrixCredentials('t2_missing', refreshed, 200)).toBe(false);
|
|
});
|
|
|
|
it('re-imports one account in place and supports multiple active accounts', () => {
|
|
addAccount('t2_alice', 'alice-bot', 100);
|
|
addAccount('t2_bob', 'bob-bot', 110);
|
|
addAccount('t2_alice', 'alice-renamed', 120);
|
|
|
|
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice', 't2_bob']);
|
|
expect(accounts.get('t2_alice')).toMatchObject({
|
|
redditUsername: 'alice-renamed', createdAt: 100, updatedAt: 120, lastImportedAt: 120,
|
|
});
|
|
|
|
expect(accounts.setActive('t2_bob', false, 130)).toBe(true);
|
|
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice']);
|
|
expect(accounts.get('t2_bob')).toMatchObject({ status: 'disabled', isActive: false, updatedAt: 130 });
|
|
});
|
|
|
|
it('partitions conversations, sync state and media even when remote IDs match', () => {
|
|
addAccount('t2_alice', 'alice-bot', 100);
|
|
addAccount('t2_bob', 'bob-bot', 110);
|
|
|
|
for (const [accountId, username, updatedAt] of [
|
|
['t2_alice', 'recipient-a', 200],
|
|
['t2_bob', 'recipient-b', 300],
|
|
] as const) {
|
|
content.upsertConversation({
|
|
accountId, conversationId: 'shared-room', username, roomName: username,
|
|
payloadJson: JSON.stringify({ username }), updatedAt,
|
|
});
|
|
content.saveSyncState({
|
|
accountId, nextBatch: `cursor-${accountId}`, lastFullSyncAt: updatedAt,
|
|
lastDeltaSyncAt: updatedAt + 1, cooldownJson: JSON.stringify({ until: updatedAt + 100 }), updatedAt,
|
|
});
|
|
content.upsertMedia({
|
|
accountId, mediaId: 'shared-media', chatUsername: username, chatRoomId: 'shared-room',
|
|
sender: username, timestamp: updatedAt, originalUrl: 'mxc://reddit.com/shared',
|
|
fileName: `${accountId}.jpg`, relativePath: `${accountId}/shared.jpg`, createdAt: updatedAt,
|
|
});
|
|
}
|
|
|
|
expect(content.getConversation('t2_alice', 'shared-room')?.username).toBe('recipient-a');
|
|
expect(content.getConversation('t2_bob', 'shared-room')?.username).toBe('recipient-b');
|
|
expect(content.getSyncState('t2_alice')?.nextBatch).toBe('cursor-t2_alice');
|
|
expect(content.getSyncState('t2_bob')?.nextBatch).toBe('cursor-t2_bob');
|
|
expect(content.getMedia('t2_alice', 'shared-media')?.fileName).toBe('t2_alice.jpg');
|
|
expect(content.getMedia('t2_bob', 'shared-media')?.fileName).toBe('t2_bob.jpg');
|
|
});
|
|
|
|
it('enforces account ownership foreign keys', () => {
|
|
expect(() => content.upsertConversation({
|
|
accountId: 'missing', conversationId: 'room', username: 'someone', roomName: null,
|
|
payloadJson: '{}', updatedAt: 1,
|
|
})).toThrow(/FOREIGN KEY/);
|
|
expect(db.pragma('foreign_key_check')).toEqual([]);
|
|
});
|
|
|
|
it('scopes legacy account-produced table uniqueness by account', () => {
|
|
addAccount('t2_alice', 'alice-bot', 100);
|
|
addAccount('t2_bob', 'bob-bot', 110);
|
|
|
|
const insertPost = db.prepare(`
|
|
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
|
|
VALUES (?, 'same-post', 'test', 'recipient', 'title', 1)
|
|
`);
|
|
insertPost.run('t2_alice');
|
|
insertPost.run('t2_bob');
|
|
|
|
const insertOutbox = db.prepare(`
|
|
INSERT INTO delivery_outbox (
|
|
account_id, post_id, recipient, subject, message, idempotency_key,
|
|
status, created_at, updated_at
|
|
) VALUES (?, 'same-post', 'recipient', 'subject', 'message', 'same-key', 'prepared', 1, 1)
|
|
`);
|
|
insertOutbox.run('t2_alice');
|
|
insertOutbox.run('t2_bob');
|
|
expect(() => insertOutbox.run('t2_alice')).toThrow(/UNIQUE/);
|
|
expect(db.prepare('SELECT COUNT(*) count FROM delivery_outbox').get()).toEqual({ count: 2 });
|
|
});
|
|
|
|
it('migrates existing rows to the explicit legacy account without guessing ownership', () => {
|
|
const legacyDb = new Database(':memory:');
|
|
legacyDb.pragma('foreign_keys = ON');
|
|
legacyDb.exec(`
|
|
CREATE TABLE processed_posts (id TEXT PRIMARY KEY, subreddit TEXT NOT NULL, author TEXT NOT NULL, title TEXT NOT NULL, image_url TEXT, image_analysis TEXT, generated_message TEXT, processed_at INTEGER NOT NULL, message_sent INTEGER DEFAULT 0, error TEXT);
|
|
CREATE TABLE sent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, recipient TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, sent_at INTEGER NOT NULL);
|
|
CREATE TABLE activity_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, recipient TEXT, image_url TEXT);
|
|
INSERT INTO processed_posts VALUES ('legacy-post', 'test', 'author', 'title', NULL, NULL, NULL, 1, 1, NULL);
|
|
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('legacy-post', 'recipient', 'subject', 'message', 2);
|
|
INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'legacy activity', 'recipient');
|
|
`);
|
|
|
|
runMigrations(legacyDb);
|
|
|
|
expect(legacyDb.prepare('SELECT account_id, id FROM processed_posts').get()).toEqual({
|
|
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID, id: 'legacy-post',
|
|
});
|
|
expect(legacyDb.prepare('SELECT account_id FROM sent_messages').get()).toEqual({
|
|
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID,
|
|
});
|
|
expect(legacyDb.prepare('SELECT account_id FROM activity_log').get()).toEqual({
|
|
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID,
|
|
});
|
|
expect(legacyDb.prepare('SELECT status, is_active FROM reddit_accounts WHERE account_id = ?')
|
|
.get(LEGACY_UNASSIGNED_ACCOUNT_ID)).toEqual({ status: 'legacy-unassigned', is_active: 0 });
|
|
expect(legacyDb.pragma('foreign_key_check')).toEqual([]);
|
|
legacyDb.close();
|
|
});
|
|
it('imports legacy chat and media JSON once as explicitly unassigned content', () => {
|
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-legacy-content-'));
|
|
try {
|
|
fs.mkdirSync(path.join(dataDirectory, 'media'));
|
|
fs.writeFileSync(path.join(dataDirectory, 'chat-cache.json'), JSON.stringify({
|
|
chats: [{ username: 'alice', roomName: 'Alice', roomId: 'room-alice', lastMessageTime: 1 }],
|
|
nextBatch: 'cursor-one', lastFullSyncAt: 10, lastDeltaSyncAt: 11, cooldown: null,
|
|
}));
|
|
fs.writeFileSync(path.join(dataDirectory, 'media', 'index.json'), JSON.stringify({
|
|
items: [{ id: 'media-one', chatUsername: 'alice', chatRoomId: 'room-alice', sender: 'alice', timestamp: 12, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 13 }],
|
|
}));
|
|
|
|
expect(importLegacyAccountContent(db, dataDirectory)).toEqual({ conversations: 1, media: 1, skipped: false });
|
|
expect(content.listConversations(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1);
|
|
expect(content.listMedia(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1);
|
|
expect(content.getSyncState(LEGACY_UNASSIGNED_ACCOUNT_ID)?.nextBatch).toBe('cursor-one');
|
|
expect(importLegacyAccountContent(db, dataDirectory).skipped).toBe(true);
|
|
} finally {
|
|
fs.rmSync(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|