Adds a Notifications tab to Settings so an operator can pick which events reach their phone, published to a single self-hosted ntfy topic. The notifier subscribes to the realtime event hub the dashboard already consumes, so every notifiable event keeps exactly one publisher. Inbound media had no event, so `media.received` is added and published from the existing durable `gotImage` gate in the chat service — one notification per conversation, never repeated when the room re-syncs. It feeds the in-app notification bell as well. - Best-effort delivery: a rejecting or unreachable ntfy server is logged and never propagated into the path that produced the event. - Events buffered before the notifier attaches are skipped, so a restart cannot replay a backlog to somebody's phone. - Settings are applied field by field onto the live config object, so a save takes effect without a restart. - The access token is environment-owned (NTFY_AUTH_TOKEN) like the other secrets: stripped before the settings record is written, never returned by the API, which reports only `hasAuthToken`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { ConfigStore } from '../src/config/persistence.js';
|
|
import { loadConfig } from '../src/config/index.js';
|
|
import { runMigrations } from '../src/database/client.js';
|
|
import { getTestConfig } from './testUtils.js';
|
|
|
|
describe('ConfigStore', () => {
|
|
let db: Database.Database;
|
|
let store: ConfigStore;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
store = new ConfigStore(db);
|
|
});
|
|
|
|
afterEach(() => db.close());
|
|
|
|
it('round-trips application configuration through SQLite', () => {
|
|
const config = getTestConfig({
|
|
rateLimit: { minDelayBetweenMessagesMs: 42, maxMessagesPerHour: 7, jitterMs: 3 },
|
|
});
|
|
store.save(config);
|
|
|
|
const loaded = store.load({
|
|
OPENROUTER_API_KEY: 'environment-key',
|
|
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
|
|
DATABASE_PATH: './data/from-environment.db',
|
|
});
|
|
|
|
expect(loaded?.rateLimit).toEqual(config.rateLimit);
|
|
expect(loaded?.openrouter.apiKey).toBe('environment-key');
|
|
expect(loaded?.web.authToken).toBe('environment-dashboard-token');
|
|
expect(loaded?.database.path).toBe('./data/from-environment.db');
|
|
});
|
|
|
|
it('never stores secrets in the database record', () => {
|
|
const config = getTestConfig();
|
|
config.openrouter.apiKey = 'database-must-not-contain-this';
|
|
config.web.authToken = 'also-secret';
|
|
config.web.agentReadToken = 'read-agent-secret';
|
|
config.web.agentControlToken = 'control-agent-secret';
|
|
config.pushNotifications.authToken = 'ntfy-secret';
|
|
store.save(config);
|
|
|
|
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
|
|
expect(row.config_json).not.toContain('database-must-not-contain-this');
|
|
expect(row.config_json).not.toContain('also-secret');
|
|
expect(row.config_json).not.toContain('read-agent-secret');
|
|
expect(row.config_json).not.toContain('control-agent-secret');
|
|
expect(row.config_json).not.toContain('ntfy-secret');
|
|
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
|
|
});
|
|
|
|
it('takes the ntfy access token from the environment on every load', () => {
|
|
const config = getTestConfig();
|
|
config.pushNotifications.topic = 'redditbot-7f3a9c21';
|
|
config.pushNotifications.authToken = 'saved-token-must-be-ignored';
|
|
store.save(config);
|
|
|
|
const withToken = store.load({
|
|
OPENROUTER_API_KEY: 'environment-key',
|
|
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
|
|
NTFY_AUTH_TOKEN: 'environment-ntfy-token',
|
|
});
|
|
const withoutToken = store.load({
|
|
OPENROUTER_API_KEY: 'environment-key',
|
|
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
|
|
});
|
|
|
|
expect(withToken?.pushNotifications.topic).toBe('redditbot-7f3a9c21');
|
|
expect(withToken?.pushNotifications.authToken).toBe('environment-ntfy-token');
|
|
expect(withoutToken?.pushNotifications.authToken).toBeUndefined();
|
|
});
|
|
|
|
it('updates the singleton record instead of appending versions', () => {
|
|
store.save(getTestConfig());
|
|
store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
|
|
|
|
expect(db.prepare('SELECT COUNT(*) AS count FROM app_config').get()).toEqual({ count: 1 });
|
|
expect(store.load({
|
|
OPENROUTER_API_KEY: 'key',
|
|
WEB_DASHBOARD_TOKEN: 'dashboard-token',
|
|
})?.browser.dryRun).toBe(true);
|
|
});
|
|
|
|
it('imports legacy YAML once and then starts without the file', () => {
|
|
const originalDirectory = process.cwd();
|
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-config-import-'));
|
|
const previousApiKey = process.env.OPENROUTER_API_KEY;
|
|
const previousDashboardToken = process.env.WEB_DASHBOARD_TOKEN;
|
|
try {
|
|
process.chdir(tempDirectory);
|
|
process.env.OPENROUTER_API_KEY = 'environment-key';
|
|
process.env.WEB_DASHBOARD_TOKEN = 'environment-dashboard-token';
|
|
fs.writeFileSync('config.yaml', `
|
|
reddit:
|
|
subreddits: [example]
|
|
openrouter:
|
|
imageAnalysisPrompt: analyze
|
|
messageGenerationPrompt: compose
|
|
`);
|
|
|
|
expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
|
|
fs.rmSync('config.yaml');
|
|
expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
|
|
} finally {
|
|
process.chdir(originalDirectory);
|
|
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
if (previousApiKey === undefined) delete process.env.OPENROUTER_API_KEY;
|
|
else process.env.OPENROUTER_API_KEY = previousApiKey;
|
|
if (previousDashboardToken === undefined) delete process.env.WEB_DASHBOARD_TOKEN;
|
|
else process.env.WEB_DASHBOARD_TOKEN = previousDashboardToken;
|
|
}
|
|
});
|
|
});
|