feat: add agent API and operational analytics

This commit is contained in:
jjbrunton committed 2026-08-08 22:08:14 +01:00
1 parent 720ea0652a
commit e8b730b592
65 files changed
+2650 -1214

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { AgentJobManager } from '../src/web/agent-job-manager.js';
describe('AgentJobManager', () => {
it('tracks an asynchronous job through completion', async () => {
const execute = vi.fn(async () => ({ processed: 4 }));
const manager = new AgentJobManager(execute);
const created = manager.create({ type: 'chat-sync', kind: 'delta' });
expect(created.status).toBe('queued');
await vi.waitFor(() => expect(manager.get(created.id)?.status).toBe('succeeded'));
expect(manager.get(created.id)).toMatchObject({ result: { processed: 4 }, error: null });
});
it('records executor failures without losing the job', async () => {
const manager = new AgentJobManager(async () => { throw new Error('sync unavailable'); });
const created = manager.create({ type: 'media-sync', kind: 'full' });
await vi.waitFor(() => expect(manager.get(created.id)?.status).toBe('failed'));
expect(manager.get(created.id)?.error).toBe('sync unavailable');
});
});
+52
View File
@@ -0,0 +1,52 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { API_OPERATIONS, capabilities, createOpenApiDocument } from '../src/web/api-catalog.js';
describe('agent API discovery', () => {
it('publishes every operation under a unique method and path', () => {
const keys = API_OPERATIONS.map((operation) => `${operation.method} ${operation.path}`);
expect(new Set(keys).size).toBe(keys.length);
expect(keys).toContain('GET /api/v1/overview');
expect(keys).toContain('POST /api/v1/control/start');
expect(keys).toContain('POST /api/v1/control/stop');
expect(keys).toContain('POST /api/v1/jobs');
expect(keys).toContain('GET /api/events/stream');
});
it('cannot drift from the Express route modules', () => {
const routesDirectory = path.resolve(process.cwd(), 'src/web/routes');
const implemented = fs.readdirSync(routesDirectory)
.filter((file) => file.endsWith('.ts'))
.flatMap((file) => {
const source = fs.readFileSync(path.join(routesDirectory, file), 'utf8');
return [...source.matchAll(/router\.(get|post|put|delete|patch)\('([^']+)'/g)].map((match) => {
const routePath = match[2] === '/healthz' ? match[2] : `/api${match[2]}`;
return `${match[1].toUpperCase()} ${routePath.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`;
});
});
const catalogued = API_OPERATIONS.map((item) => `${item.method} ${item.path}`);
expect([...new Set(catalogued)].sort()).toEqual([...new Set(implemented)].sort());
});
it('generates OpenAPI with safety metadata and schemas for versioned operations', () => {
const document = createOpenApiDocument() as { openapi: string; paths: Record<string, Record<string, Record<string, unknown>>> };
expect(document.openapi).toBe('3.1.0');
expect(document.paths['/healthz'].get.security).toEqual([]);
expect(document.paths['/api/v1/overview'].get['x-access']).toBe('read');
expect(document.paths['/api/v1/control/start'].post['x-access']).toBe('control');
expect(document.paths['/api/v1/control/start'].post['x-mutates']).toBe(true);
expect(document.paths['/api/v1/jobs'].post.requestBody).toBeDefined();
expect((document.paths['/api/v1/jobs'].post.responses as Record<string, unknown>)['202']).toBeDefined();
});
it('returns a compact capability catalogue for agents', () => {
const result = capabilities();
expect(result.authentication.agentScopes).toEqual(['read', 'control', 'admin']);
expect(result.operations.find((item) => item.operationId === 'sendReply')).toMatchObject({
access: 'admin',
mutates: true,
});
});
});
@@ -85,13 +85,9 @@ describe('ChatService progressive full backfill', () => {
expect(repos.messagesRepo.getResponseRateTrend()).toEqual([
expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }),
]);
expect(repos.successfulMessagesRepo.getRecent()).toEqual([
expect.objectContaining({
originalMessage: 'Hello Alice',
postTitle: 'Source post',
subreddit: 'example',
}),
]);
// A single reply counts toward response performance, but the
// reciprocal-engagement policy excludes it from learning examples.
expect(repos.successfulMessagesRepo.getRecent()).toEqual([]);
} finally {
db.close();
await fs.rm(tmpDir, { recursive: true, force: true });
+4
View File
@@ -42,11 +42,15 @@ describe('ConfigStore', () => {
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';
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(JSON.parse(row.config_json)).not.toHaveProperty('database');
});
+2 -1
View File
@@ -221,7 +221,8 @@ describe('ConfigSchema', () => {
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.strategies.explorationRate).toBe(0.2);
expect(result.data.strategies.minSamples).toBe(5);
expect(result.data.strategies.minSamples).toBe(30);
expect(result.data.strategies.responseWindowHours).toBe(48);
expect(result.data.strategies.variants).toEqual([]);
}
});
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import type {
ConversationOutcomesRepository,
MessagesRepository,
SentMessageRecord,
SuccessfulMessagesRepository,
} from '../src/database/repositories.js';
import { recordConversationReply } from '../src/services/conversation-outcome-recorder.js';
import { createMockLogger } from './mockLogger.js';
function message(): SentMessageRecord {
return {
id: 1,
postId: 'post-1',
recipient: 'alice',
subject: 'alice',
message: 'Hello',
sentAt: 1,
gotResponse: false,
gotImage: false,
responseCheckedAt: null,
strategyId: 'direct',
chatRoomId: 'room-1',
subreddit: 'example',
};
}
function dependencies() {
return {
messages: { markGotResponse: vi.fn() } as unknown as MessagesRepository,
successful: { insert: vi.fn(), existsByMessage: vi.fn(() => false) } as unknown as SuccessfulMessagesRepository,
outcomes: { record: vi.fn(), suppressRecipient: vi.fn() } as unknown as ConversationOutcomesRepository,
logger: createMockLogger(),
};
}
describe('recordConversationReply', () => {
it('records a first reply but excludes it from learning until engagement is reciprocal', () => {
const deps = dependencies();
expect(recordConversationReply(message(), 'Thanks!', { messageCount: 2, reciprocal: false }, deps)).toBe('unknown');
expect(deps.messages.markGotResponse).toHaveBeenCalledWith(1);
expect(deps.successful.insert).not.toHaveBeenCalled();
});
it('stores only reciprocal engaged conversations as learning examples', () => {
const deps = dependencies();
expect(recordConversationReply(message(), 'Tell me more', { messageCount: 4, reciprocal: true }, deps)).toBe('engaged');
expect(deps.successful.insert).toHaveBeenCalledWith(expect.objectContaining({ sentMessageId: 1 }));
});
it('permanently suppresses a recipient after a boundary response', () => {
const deps = dependencies();
expect(recordConversationReply(message(), 'Stop messaging me', { messageCount: 2 }, deps)).toBe('boundary');
expect(deps.outcomes.suppressRecipient).toHaveBeenCalledWith('alice', 'boundary');
expect(deps.successful.insert).not.toHaveBeenCalled();
});
});
+8 -1
View File
@@ -3,11 +3,18 @@ import { classifyConversationOutcome } from '../src/services/conversation-outcom
describe('conversation outcome classification', () => {
it.each([
['That sounds fun, tell me more', 'engaged'],
['That sounds fun, tell me more', 'unknown'],
['No thanks, not interested', 'declined'],
['Check my OnlyFans link in bio', 'upsell'],
['Stop messaging me', 'boundary'],
] as const)('classifies %s as %s', (reply, expected) => {
expect(classifyConversationOutcome(reply)).toBe(expected);
});
it('classifies a reciprocal multi-message conversation as engaged', () => {
expect(classifyConversationOutcome('That sounds fun, tell me more', {
messageCount: 4,
reciprocal: true,
})).toBe('engaged');
});
});
@@ -68,11 +68,26 @@ describe('PerformancePage', () => {
},
]),
),
http.get('/api/analytics/funnel', () => HttpResponse.json({
responseWindowHours: 48,
processed: 100,
sent: 40,
pending: 5,
mature: 35,
responded: 12,
engaged: 4,
declined: 2,
boundary: 1,
upsell: 3,
images: 2,
})),
);
renderPage(<PerformancePage />);
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
+1 -1
View File
@@ -107,7 +107,7 @@ describe('Database Client', () => {
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(13);
expect(tables.length).toBe(14);
});
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { RedditBotApiClient, RedditBotApiError } from '../src/mcp/api-client.js';
describe('RedditBotApiClient', () => {
it('uses a bearer token and parses JSON responses', async () => {
const fetchImplementation = vi.fn(async () => new Response(JSON.stringify({ running: true }), { status: 200 }));
const client = new RedditBotApiClient({
baseUrl: 'http://redditbot.test/',
token: 'read-token',
fetchImplementation: fetchImplementation as typeof fetch,
});
await expect(client.get('/api/v1/control')).resolves.toEqual({ running: true });
expect(fetchImplementation).toHaveBeenCalledWith('http://redditbot.test/api/v1/control', expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer read-token' }),
}));
});
it('returns structured API errors to MCP tools', async () => {
const client = new RedditBotApiClient({
token: 'read-token',
fetchImplementation: vi.fn(async () => new Response(JSON.stringify({ error: 'Agent credential lacks the required access' }), { status: 403 })) as typeof fetch,
});
await expect(client.post('/api/v1/control/start')).rejects.toEqual(
new RedditBotApiError('Agent credential lacks the required access', 403),
);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RedditBotApiClient } from '../src/mcp/api-client.js';
import { createRedditBotMcpServer } from '../src/mcp/server.js';
describe('RedditBot MCP server', () => {
const closeables: Array<{ close(): Promise<void> }> = [];
afterEach(async () => {
await Promise.all(closeables.splice(0).map((item) => item.close()));
});
async function connectedClient() {
const get = vi.fn(async () => ({ status: 'operational' }));
const post = vi.fn(async () => ({ success: true, running: true }));
const server = createRedditBotMcpServer({ get, post } as unknown as RedditBotApiClient);
const client = new Client({ name: 'test-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
closeables.push(client, server);
return { client, get, post };
}
it('advertises read, control, and explicit external-send tools', async () => {
const { client } = await connectedClient();
const tools = await client.listTools();
const names = tools.tools.map((tool) => tool.name);
expect(names).toEqual(expect.arrayContaining([
'get_overview', 'list_accounts', 'list_conversations', 'start_bot', 'stop_bot',
'set_bot_running', 'trigger_sync', 'send_reply', 'start_conversation',
]));
expect(tools.tools.find((tool) => tool.name === 'get_overview')?.annotations?.readOnlyHint).toBe(true);
expect(tools.tools.find((tool) => tool.name === 'send_reply')?.annotations?.destructiveHint).toBe(true);
});
it('routes start and stop tools to the versioned control API', async () => {
const { client, post } = await connectedClient();
await client.callTool({ name: 'start_bot', arguments: {} });
await client.callTool({ name: 'stop_bot', arguments: {} });
expect(post).toHaveBeenNthCalledWith(1, '/api/v1/control/start');
expect(post).toHaveBeenNthCalledWith(2, '/api/v1/control/stop');
});
});
+20 -3
View File
@@ -17,6 +17,7 @@ function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient {
function createMockPostsRepo(processedIds: Set<string> = new Set()): PostsRepository {
return {
isProcessed: vi.fn((id: string) => processedIds.has(id)),
isProcessedAnywhere: vi.fn((id: string) => processedIds.has(id)),
insert: vi.fn(),
updateAnalysis: vi.fn(),
markMessageSent: vi.fn(),
@@ -86,6 +87,23 @@ describe('MonitorService', () => {
expect(result.length).toBe(0);
});
it('uses shared post history so another account cannot rediscover a post', async () => {
const post: RedditPost = {
id: 't3_shared', subreddit: 'sub1', author: 'user1', title: 'Post',
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false,
};
const redditApi = createMockRedditApi([post]);
const postsRepo = createMockPostsRepo();
vi.mocked(postsRepo.isProcessed).mockReturnValue(false);
vi.mocked(postsRepo.isProcessedAnywhere).mockReturnValue(true);
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([]);
expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith(post.id);
});
it('skips posts from users already messaged', async () => {
const post1: RedditPost = {
id: 't3_1', subreddit: 'sub1', author: 'already_msgd', title: 'Post 1',
@@ -370,7 +388,7 @@ describe('MonitorService', () => {
// (We can't directly test the shuffle, but we can verify it doesn't crash)
});
it('limits results to 10 per author via postsRepo', async () => {
it('checks shared processed-post history before considering a candidate', async () => {
const post: RedditPost = {
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post',
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
@@ -385,8 +403,7 @@ describe('MonitorService', () => {
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
await monitor.getUnprocessedPosts();
// Verify isProcessed was called
expect(postsRepo.isProcessed).toHaveBeenCalledWith('t3_1');
expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith('t3_1');
});
describe('image/chat interleaving', () => {
+18
View File
@@ -3,6 +3,7 @@ import axios from 'axios';
import type { AxiosInstance } from 'axios';
import type { BrowserContext } from 'playwright';
import { RedditApiClient, RedditRateLimitError } from '../src/reddit/api.js';
import type { RedditRequestScheduler } from '../src/reddit/request-coordinator.js';
import type { RedditPost } from '../src/reddit/api.js';
import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js';
@@ -517,6 +518,23 @@ describe('RedditApiClient', () => {
)).toBe(true);
});
it('extends the shared request cooldown when Reddit returns 429', async () => {
const error = {
isAxiosError: true,
response: { status: 429, headers: { 'retry-after': '12' } },
};
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const scheduler: RedditRequestScheduler = {
schedule: async (request) => await request(),
extendCooldown: vi.fn(),
};
const client2 = new RedditApiClient(getTestConfig(), logger, scheduler);
await expect(client2.getNewPosts('test')).rejects.toMatchObject({ retryAfterMs: 12_000 });
expect(scheduler.extendCooldown).toHaveBeenCalledWith(12_000);
});
it('parses valid image posts from response', async () => {
const mockResponse = {
data: { data: { children: [
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { RedditRequestCoordinator } from '../src/reddit/request-coordinator.js';
describe('RedditRequestCoordinator', () => {
it('paces requests and applies a shared cooldown to the next queued request', async () => {
let now = 0;
const sleeps: number[] = [];
const coordinator = new RedditRequestCoordinator(6_500, {
now: () => now,
sleep: async (ms) => { sleeps.push(ms); now += ms; },
});
await coordinator.schedule(async () => undefined);
coordinator.extendCooldown(30_000);
await coordinator.schedule(async () => undefined);
expect(sleeps).toEqual([31_000]);
});
it('serializes concurrent requests through one process-wide queue', async () => {
const order: string[] = [];
let releaseFirst!: () => void;
const firstFinished = new Promise<void>((resolve) => { releaseFirst = resolve; });
const coordinator = new RedditRequestCoordinator(0);
const first = coordinator.schedule(async () => {
order.push('first:start');
await firstFinished;
order.push('first:end');
});
const second = coordinator.schedule(async () => { order.push('second'); });
await Promise.resolve();
expect(order).toEqual(['first:start']);
releaseFirst();
await Promise.all([first, second]);
expect(order).toEqual(['first:start', 'first:end', 'second']);
});
it('does not release a queued request before a failing request records its cooldown', async () => {
let now = 0;
const sleeps: number[] = [];
const coordinator = new RedditRequestCoordinator(0, {
now: () => now,
sleep: async (ms) => { sleeps.push(ms); now += ms; },
});
const first = coordinator.schedule(async () => {
coordinator.extendCooldown(15_000);
throw new Error('429');
});
const second = coordinator.schedule(async () => undefined);
await expect(first).rejects.toThrow('429');
await second;
expect(sleeps).toEqual([16_000]);
});
});
+33
View File
@@ -6,6 +6,8 @@ import {
MessagesRepository,
SuccessfulMessagesRepository,
ConversationStateRepository,
ConversationOutcomesRepository,
RecipientHistoryRepository,
} from '../src/database/repositories.js';
import { buildConversationState } from '../src/services/conversation-state.js';
@@ -607,6 +609,37 @@ describe('ConversationStateRepository', () => {
});
});
describe('Mature performance and guardrails', () => {
it('excludes pending sends and reports a confidence interval', () => {
const { postsRepo, messagesRepo } = createTestRepositories();
postsRepo.insert({ id: 'old', subreddit: 'test', author: 'old-user', title: 'old', imageUrl: null, processedAt: 1 });
postsRepo.insert({ id: 'new', subreddit: 'test', author: 'new-user', title: 'new', imageUrl: null, processedAt: 1 });
const oldId = messagesRepo.insert({
postId: 'old', recipient: 'old-user', subject: 's', message: 'm',
sentAt: Date.now() - 72 * 3600000, strategyId: 'direct',
});
messagesRepo.markGotResponse(oldId);
messagesRepo.insert({
postId: 'new', recipient: 'new-user', subject: 's', message: 'm',
sentAt: Date.now(), strategyId: 'direct',
});
expect(messagesRepo.getStrategyStats(Date.now() - 48 * 3600000)).toEqual([
expect.objectContaining({
strategyId: 'direct', totalSent: 1, totalResponses: 1, responseRate: 100,
pendingSent: 1, responseRateLow: expect.any(Number), responseRateHigh: expect.any(Number),
}),
]);
});
it('suppresses boundary recipients across account history checks', () => {
const db = createTestDb();
const outcomes = new ConversationOutcomesRepository(db);
outcomes.suppressRecipient('Alice', 'boundary');
expect(new RecipientHistoryRepository(db).hasSentTo('alice')).toBe(true);
});
});
describe('Repository edge cases', () => {
it('handles special characters in post titles', () => {
const repos = createTestRepositories();
+3 -8
View File
@@ -194,7 +194,7 @@ describe('ResponseCheckerService', () => {
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
});
it('stores successful messages for learning', async () => {
it('does not treat a single inbox reply as an engaged learning example', async () => {
const post: ProcessedPostRecord = {
id: 'post1',
subreddit: 'gonewild',
@@ -245,13 +245,8 @@ describe('ResponseCheckerService', () => {
});
await checker.checkForResponses();
expect(successfulMessagesRepo.insert).toHaveBeenCalled();
const insertCall = vi.mocked(successfulMessagesRepo.insert).mock.calls[0][0];
expect(insertCall.originalMessage).toBe('Hey gorgeous!');
expect(insertCall.imageAnalysis).toBe('Beautiful woman');
expect(insertCall.postTitle).toBe('My selfie');
expect(insertCall.subreddit).toBe('gonewild');
expect(messagesRepo.markGotResponse).toHaveBeenCalledWith(1);
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
});
it('does not turn a declined reply into a learning example', async () => {
+3
View File
@@ -14,6 +14,7 @@ export function createTestDb(): Database.Database {
// Disable FK for tests - we manage referential integrity manually
db.pragma('foreign_keys = OFF');
runMigrations(db);
db.pragma('foreign_keys = OFF');
return db;
}
@@ -77,6 +78,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
strategies: {
explorationRate: 0.2,
minSamples: 5,
responseWindowHours: 48,
variants: [
{ id: 'variant-a', weight: 1, promptOverride: 'Be direct' },
{ id: 'variant-b', weight: 1, promptOverride: 'Be teasing' },
@@ -86,6 +88,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
chatStrategies: {
explorationRate: 0.2,
minSamples: 5,
responseWindowHours: 48,
variants: [],
...overrides?.chatStrategies,
},
+55 -2
View File
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import type { BrowserClient } from '../src/browser/client.js';
import { ActivityLogRepository } from '../src/database/repositories.js';
import { WebServer } from '../src/web/server.js';
import { OverviewResponseSchema } from '../src/web/agent-api-contracts.js';
import { createMockLogger } from './mockLogger.js';
import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js';
@@ -31,13 +32,25 @@ function createWorkspace(includeReactBuild = true): string {
return workspace;
}
async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise<RunningServer> {
async function startServer(options: {
debugEnabled?: boolean;
includeReactBuild?: boolean;
agentReadToken?: string;
agentControlToken?: string;
} = {}): Promise<RunningServer> {
const database = createTestDb();
const repositories = createTestRepositories(database);
const workspace = createWorkspace(options.includeReactBuild);
const server = new WebServer(
0,
getTestConfig({ web: { enabled: true, port: 0, authToken: AUTH_TOKEN, debugEnabled: options.debugEnabled ?? false } }),
getTestConfig({ web: {
enabled: true,
port: 0,
authToken: AUTH_TOKEN,
debugEnabled: options.debugEnabled ?? false,
agentReadToken: options.agentReadToken,
agentControlToken: options.agentControlToken,
} }),
repositories.postsRepo,
repositories.messagesRepo,
repositories.successfulMessagesRepo,
@@ -110,4 +123,44 @@ describe('authenticated React dashboard cutover', () => {
expect(upload.status).toBe(404);
});
it('exposes public liveness and scoped discovery/control APIs', async () => {
const running = await startServer({
agentReadToken: 'agent-read-token',
agentControlToken: 'agent-control-token',
});
runningServers.push(running);
running.server.setAccountAuth({
isFullyAuthenticated: () => true,
} as never);
const health = await fetch(`${running.baseUrl}/healthz`);
const openapi = await fetch(`${running.baseUrl}/api/openapi.json`, {
headers: { Authorization: 'Bearer agent-read-token' },
});
const overview = await fetch(`${running.baseUrl}/api/v1/overview`, {
headers: { Authorization: 'Bearer agent-read-token' },
});
const forbiddenStart = await fetch(`${running.baseUrl}/api/v1/control/start`, {
method: 'POST',
headers: { Authorization: 'Bearer agent-read-token' },
});
const allowedStart = await fetch(`${running.baseUrl}/api/v1/control/start`, {
method: 'POST',
headers: { Authorization: 'Bearer agent-control-token' },
});
const allowedStop = await fetch(`${running.baseUrl}/api/v1/control/stop`, {
method: 'POST',
headers: { Authorization: 'Bearer agent-control-token' },
});
expect(health.status).toBe(200);
expect(await health.json()).toMatchObject({ status: 'ok', service: 'reddit-bot' });
expect(openapi.status).toBe(200);
expect(await openapi.json()).toMatchObject({ openapi: '3.1.0' });
expect(OverviewResponseSchema.safeParse(await overview.json()).success).toBe(true);
expect(forbiddenStart.status).toBe(403);
expect(await allowedStart.json()).toMatchObject({ success: true, running: true, changed: true });
expect(await allowedStop.json()).toMatchObject({ success: true, running: false, changed: true });
});
});
+22
View File
@@ -48,6 +48,28 @@ describe('dashboard authentication', () => {
expect(await response.json()).toEqual({ ok: true });
});
it('limits agent bearer tokens to their declared access', async () => {
const app = express();
app.use(express.json());
app.use(createDashboardAuth('dashboard-secret', {
readToken: 'agent-read-secret',
controlToken: 'agent-control-secret',
}));
app.get('/api/v1/overview', (_req, res) => res.json({ ok: true }));
app.post('/api/v1/control/start', (_req, res) => res.json({ ok: true }));
app.post('/api/conversations/start', (_req, res) => res.json({ ok: true }));
const read = await request(app, '/api/v1/overview', { headers: { Authorization: 'Bearer agent-read-secret' } });
const readMutation = await request(app, '/api/v1/control/start', { method: 'POST', headers: { Authorization: 'Bearer agent-read-secret' } });
const control = await request(app, '/api/v1/control/start', { method: 'POST', headers: { Authorization: 'Bearer agent-control-secret' } });
const legacyMutation = await request(app, '/api/conversations/start', { method: 'POST', headers: { Authorization: 'Bearer agent-control-secret' } });
expect(read.status).toBe(200);
expect(readMutation.status).toBe(403);
expect(control.status).toBe(200);
expect(legacyMutation.status).toBe(403);
});
it('issues an HttpOnly session only for a schema-valid token payload', async () => {
const app = express();
app.use(express.json());
+1
View File
@@ -11,6 +11,7 @@ describe('control route realtime events', () => {
const context = {
config: { browser: { dryRun: true } },
services: { accountAuth: { isFullyAuthenticated: () => true } },
getBotRunning: () => running,
setBotRunning: (next: boolean) => { running = next; },
activity: { push: vi.fn() },
logger: { info: vi.fn() },