From 95504d4b092f1d6560d3dca1c318d79b87117e1e Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Fri, 24 Apr 2026 11:47:26 -0700 Subject: [PATCH] test: add 196 tests across MCP, services, utils, autocomplete (#855) Co-authored-by: Jwalin Shah Co-authored-by: Steven Enamakel --- app/src/lib/mcp/errorHandler.test.ts | 82 +++++ app/src/lib/mcp/rateLimiter.test.ts | 162 +++++++++ app/src/lib/mcp/transport.test.ts | 218 +++++++++++++ app/src/lib/mcp/validation.test.ts | 121 +++++++ app/src/services/api/tunnelsApi.test.ts | 226 +++++++++++++ app/src/services/coreCommandClient.test.ts | 91 ++++++ app/src/services/coreStateApi.test.ts | 235 ++++++++++++++ app/src/services/errorReportQueue.test.ts | 361 +++++++++++++++++++++ app/src/utils/cryptoKeys.test.ts | 129 ++++++++ app/src/utils/withTimeout.test.ts | 118 +++++++ src/openhuman/autocomplete/ops.rs | 290 +++++++++++++++++ tests/json_rpc_e2e.rs | 141 ++++++++ 12 files changed, 2174 insertions(+) create mode 100644 app/src/lib/mcp/errorHandler.test.ts create mode 100644 app/src/lib/mcp/rateLimiter.test.ts create mode 100644 app/src/lib/mcp/transport.test.ts create mode 100644 app/src/lib/mcp/validation.test.ts create mode 100644 app/src/services/api/tunnelsApi.test.ts create mode 100644 app/src/services/coreCommandClient.test.ts create mode 100644 app/src/services/coreStateApi.test.ts create mode 100644 app/src/services/errorReportQueue.test.ts create mode 100644 app/src/utils/cryptoKeys.test.ts create mode 100644 app/src/utils/withTimeout.test.ts diff --git a/app/src/lib/mcp/errorHandler.test.ts b/app/src/lib/mcp/errorHandler.test.ts new file mode 100644 index 000000000..24a6040cc --- /dev/null +++ b/app/src/lib/mcp/errorHandler.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for MCP error handling utilities + */ +import { describe, expect, it, vi } from 'vitest'; + +import { ErrorCategory, logAndFormatError, withErrorHandling } from './errorHandler'; +import { ValidationError } from './validation'; + +describe('logAndFormatError', () => { + it('returns an MCPToolResult with isError=true', () => { + const result = logAndFormatError('myFn', new Error('boom')); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + }); + + it('includes the error code in the user message for generic errors', () => { + const result = logAndFormatError('myFn', new Error('internal')); + expect(result.content[0].text).toMatch(/code:/); + }); + + it('exposes the ValidationError message directly to the user', () => { + const err = new ValidationError('chat_id must be a positive integer'); + const result = logAndFormatError('myFn', err, ErrorCategory.VALIDATION); + expect(result.content[0].text).toBe('chat_id must be a positive integer'); + }); + + it('includes a VALIDATION-001 error code when category is VALIDATION', () => { + const result = logAndFormatError('sendMsg', new Error('oops'), ErrorCategory.VALIDATION); + // ValidationError path is not triggered — but category affects the code + expect(result.content[0].text).toMatch(/VALIDATION-001/); + }); + + it('uses the supplied category as a prefix for non-validation errors', () => { + const result = logAndFormatError('sendMsg', new Error('fail'), ErrorCategory.CHAT); + expect(result.content[0].text).toMatch(/CHAT/); + }); + + it('produces a stable code for the same function name', () => { + const r1 = logAndFormatError('stableFn', new Error('a')); + const r2 = logAndFormatError('stableFn', new Error('b')); + // Strip the leading text, just compare the code portion + const code1 = r1.content[0].text.match(/code: ([^)]+)/)?.[1]; + const code2 = r2.content[0].text.match(/code: ([^)]+)/)?.[1]; + expect(code1).toBe(code2); + }); +}); + +describe('withErrorHandling', () => { + it('returns the wrapped function result when no error is thrown', async () => { + const fn = vi + .fn() + .mockResolvedValue({ content: [{ type: 'text' as const, text: 'ok' }], isError: false }); + const wrapped = withErrorHandling(fn); + const result = await wrapped(); + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('ok'); + }); + + it('catches a thrown Error and returns an error MCPToolResult', async () => { + const fn = vi.fn().mockRejectedValue(new Error('network down')); + const wrapped = withErrorHandling(fn, ErrorCategory.MSG); + const result = await wrapped(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/code:/); + }); + + it('catches a thrown non-Error value and wraps it', async () => { + const fn = vi.fn().mockRejectedValue('string error'); + const wrapped = withErrorHandling(fn); + const result = await wrapped(); + expect(result.isError).toBe(true); + }); + + it('exposes ValidationError message through the wrapper', async () => { + const fn = vi.fn().mockRejectedValue(new ValidationError('param must be string')); + const wrapped = withErrorHandling(fn, ErrorCategory.VALIDATION); + const result = await wrapped(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe('param must be string'); + }); +}); diff --git a/app/src/lib/mcp/rateLimiter.test.ts b/app/src/lib/mcp/rateLimiter.test.ts new file mode 100644 index 000000000..3f8c8482a --- /dev/null +++ b/app/src/lib/mcp/rateLimiter.test.ts @@ -0,0 +1,162 @@ +/** + * Unit tests for MCP rate limiter + * + * Note: tests that would exercise real sleeping (inter-call delays, per-minute + * window waits) are skipped to keep the suite fast. Those paths require either + * fake timers or vi.useFakeTimers() integration with async Promises, which + * conflicts with the shared mock-server setup in setup.ts. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + classifyTool, + enforceRateLimit, + getRateLimitStatus, + isHeavyTool, + isStateOnlyTool, + RATE_LIMIT_CONFIG, + resetRequestCallCount, +} from './rateLimiter'; + +// Reset module-level state before every test so tests are independent. +beforeEach(() => { + resetRequestCallCount(); +}); + +// Restore any timer fakes after each test. +afterEach(() => { + vi.useRealTimers(); +}); + +describe('classifyTool', () => { + it('classifies a known state-only tool as state_only', () => { + expect(classifyTool('get_chats')).toBe('state_only'); + }); + + it('classifies a known API read tool as api_read', () => { + expect(classifyTool('search_contacts')).toBe('api_read'); + }); + + it('classifies a known API write tool as api_write', () => { + expect(classifyTool('send_message')).toBe('api_write'); + }); + + it('defaults unknown tools to api_read', () => { + expect(classifyTool('totally_unknown_tool')).toBe('api_read'); + }); +}); + +describe('isStateOnlyTool', () => { + it('returns true for get_me', () => { + expect(isStateOnlyTool('get_me')).toBe(true); + }); + + it('returns false for send_message', () => { + expect(isStateOnlyTool('send_message')).toBe(false); + }); +}); + +describe('isHeavyTool', () => { + it('returns true for delete_message', () => { + expect(isHeavyTool('delete_message')).toBe(true); + }); + + it('returns false for get_me', () => { + expect(isHeavyTool('get_me')).toBe(false); + }); +}); + +describe('getRateLimitStatus', () => { + it('returns zero call counts at startup', () => { + const status = getRateLimitStatus(); + expect(status.callsThisRequest).toBe(0); + expect(typeof status.callsThisMinute).toBe('number'); + expect(status.callsThisMinute).toBeGreaterThanOrEqual(0); + }); +}); + +describe('enforceRateLimit — state_only tools', () => { + it('resolves immediately without incrementing the request counter', async () => { + await enforceRateLimit('get_chats'); + // state_only tools must not count against the per-request budget + expect(getRateLimitStatus().callsThisRequest).toBe(0); + }); + + it('resolves immediately even when called many times', async () => { + for (let i = 0; i < 25; i++) { + await enforceRateLimit('get_me'); + } + expect(getRateLimitStatus().callsThisRequest).toBe(0); + }); +}); + +describe('enforceRateLimit — per-request cap', () => { + it('throws when the per-request cap is exceeded', async () => { + // Use fake timers so inter-call delays resolve instantly. + vi.useFakeTimers(); + + const max = RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST; + + // Build promises and immediately attach a catch so Node never sees an + // unhandled rejection, even if the async fn throws synchronously. + const settled: Array<{ ok: true } | { ok: false; err: Error }> = []; + const promises = Array.from({ length: max + 1 }, () => { + const p = enforceRateLimit('search_contacts', 'api_read'); + p.catch(() => {}); // suppress unhandled rejection + return p; + }); + + await vi.runAllTimersAsync(); + + for (const p of promises) { + await p.then( + () => settled.push({ ok: true }), + (err: Error) => settled.push({ ok: false, err }) + ); + } + + const rejected = settled.filter(s => !s.ok); + expect(rejected.length).toBeGreaterThanOrEqual(1); + const firstRejected = rejected[0]; + if (!firstRejected.ok) { + expect(firstRejected.err.message).toMatch(/Rate limit/); + } + }); +}); + +describe('resetRequestCallCount', () => { + it('resets the per-request counter so subsequent API calls are allowed again', async () => { + vi.useFakeTimers(); + + // Fill up the request budget, suppressing rejections to avoid unhandled errors. + const max = RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST; + const fill = Array.from({ length: max }, () => { + const p = enforceRateLimit('list_contacts', 'api_read'); + p.catch(() => {}); + return p; + }); + await vi.runAllTimersAsync(); + // Drain settled results (some may reject if counter already exceeded) + await Promise.allSettled(fill); + + // Reset and confirm a subsequent call succeeds + resetRequestCallCount(); + + const afterReset = enforceRateLimit('list_contacts', 'api_read'); + await vi.runAllTimersAsync(); + await expect(afterReset).resolves.toBeUndefined(); + }); +}); + +describe('RATE_LIMIT_CONFIG', () => { + it('has the expected shape', () => { + expect(RATE_LIMIT_CONFIG.API_READ_DELAY_MS).toBeTypeOf('number'); + expect(RATE_LIMIT_CONFIG.API_WRITE_DELAY_MS).toBeTypeOf('number'); + expect(RATE_LIMIT_CONFIG.MAX_CALLS_PER_MINUTE).toBeTypeOf('number'); + expect(RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST).toBeTypeOf('number'); + // Write delay should be heavier than read delay + expect(RATE_LIMIT_CONFIG.API_WRITE_DELAY_MS).toBeGreaterThan( + RATE_LIMIT_CONFIG.API_READ_DELAY_MS + ); + }); +}); diff --git a/app/src/lib/mcp/transport.test.ts b/app/src/lib/mcp/transport.test.ts new file mode 100644 index 000000000..af5777f0c --- /dev/null +++ b/app/src/lib/mcp/transport.test.ts @@ -0,0 +1,218 @@ +/** + * Unit tests for SocketIOMCPTransportImpl + * + * The socket.io-client module is replaced with a lightweight in-process fake + * so no real network is involved. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { SocketIOMCPTransportImpl } from './transport'; +import type { MCPRequest, MCPResponse } from './types'; + +// --------------------------------------------------------------------------- +// Minimal Socket fake +// --------------------------------------------------------------------------- + +type EventHandler = (...args: unknown[]) => void; + +function makeSocket(overrides: { connected?: boolean } = {}) { + const handlers = new Map(); + + const socket = { + connected: overrides.connected ?? true, + emit: vi.fn(), + on(event: string, handler: EventHandler) { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event)!.push(handler); + }, + off(event: string, handler: EventHandler) { + const list = handlers.get(event) ?? []; + const idx = list.indexOf(handler); + if (idx !== -1) list.splice(idx, 1); + }, + /** Test helper: trigger a registered handler */ + trigger(event: string, ...args: unknown[]) { + for (const h of handlers.get(event) ?? []) { + h(...args); + } + }, + _handlers: handlers, + }; + + return socket; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRequest(id: string | number = 'req-1', method = 'test.method'): MCPRequest { + return { jsonrpc: '2.0', id, method }; +} + +function makeResponse(id: string | number, result: unknown = { ok: true }): MCPResponse { + return { jsonrpc: '2.0', id, result }; +} + +function makeErrorResponse(id: string | number, message = 'RPC error'): MCPResponse { + return { jsonrpc: '2.0', id, error: { code: -32000, message } }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SocketIOMCPTransportImpl — connected property', () => { + it('returns true when the socket is connected', () => { + const socket = makeSocket({ connected: true }); + const transport = new SocketIOMCPTransportImpl(socket as never); + expect(transport.connected).toBe(true); + }); + + it('returns false when the socket is disconnected', () => { + const socket = makeSocket({ connected: false }); + const transport = new SocketIOMCPTransportImpl(socket as never); + expect(transport.connected).toBe(false); + }); + + it('returns false when socket is null', () => { + const transport = new SocketIOMCPTransportImpl(null); + expect(transport.connected).toBe(false); + }); +}); + +describe('SocketIOMCPTransportImpl — emit', () => { + it('emits with the mcp: prefix when socket is connected', () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + transport.emit('request', { id: 1 }); + expect(socket.emit).toHaveBeenCalledWith('mcp:request', { id: 1 }); + }); + + it('does nothing when socket is disconnected', () => { + const socket = makeSocket({ connected: false }); + const transport = new SocketIOMCPTransportImpl(socket as never); + transport.emit('request', { id: 1 }); + expect(socket.emit).not.toHaveBeenCalled(); + }); +}); + +describe('SocketIOMCPTransportImpl — on / off', () => { + it('registers a handler on the prefixed event', () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + const handler = vi.fn(); + transport.on('tool_call', handler); + // Trigger via the raw socket + socket.trigger('mcp:tool_call', { data: 42 }); + expect(handler).toHaveBeenCalledWith({ data: 42 }); + }); + + // TODO: transport.off() passes the original handler reference to socket.off() instead of + // the wrapped handler registered via socket.on(). This means the wrapped handler may still + // fire after off() is called — the deregistration is a no-op in practice. Fix the bug in + // transport.ts first, then replace this todo with a behavioural assertion. + it.todo( + 'off removes the handler (pending fix: off() passes original instead of wrapped handler)' + ); +}); + +describe('SocketIOMCPTransportImpl — request / response routing', () => { + it('resolves when a matching response arrives', async () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + const req = makeRequest('abc'); + + const promise = transport.request(req); + // Simulate the backend replying + socket.trigger('mcp:response', makeResponse('abc')); + + const response = await promise; + expect(response.result).toEqual({ ok: true }); + expect(response.id).toBe('abc'); + }); + + it('rejects when the response contains an error', async () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + const req = makeRequest('err-1'); + + const promise = transport.request(req); + socket.trigger('mcp:response', makeErrorResponse('err-1', 'Not found')); + + await expect(promise).rejects.toThrow('Not found'); + }); + + it('rejects with a timeout error when no response arrives within timeoutMs', async () => { + vi.useFakeTimers(); + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + const req = makeRequest('timeout-1'); + + const promise = transport.request(req, 1000); + vi.advanceTimersByTime(1001); + + await expect(promise).rejects.toThrow(/timeout/i); + vi.useRealTimers(); + }); + + it('cleans up the handler after a successful response', async () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + const req = makeRequest('clean-1'); + + const promise = transport.request(req); + socket.trigger('mcp:response', makeResponse('clean-1')); + await promise; + + // A second response with the same id should be ignored (no handler left) + // We verify no throw occurs — the response handler logs a warn and returns. + expect(() => socket.trigger('mcp:response', makeResponse('clean-1'))).not.toThrow(); + }); + + it('rejects immediately when the socket is not connected', async () => { + const socket = makeSocket({ connected: false }); + const transport = new SocketIOMCPTransportImpl(socket as never); + await expect(transport.request(makeRequest())).rejects.toThrow('Socket not connected'); + }); + + it('routes concurrent requests independently by id', async () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + + const p1 = transport.request(makeRequest('r1')); + const p2 = transport.request(makeRequest('r2')); + + socket.trigger('mcp:response', makeResponse('r2', { data: 'second' })); + socket.trigger('mcp:response', makeResponse('r1', { data: 'first' })); + + const [res1, res2] = await Promise.all([p1, p2]); + expect((res1.result as { data: string }).data).toBe('first'); + expect((res2.result as { data: string }).data).toBe('second'); + }); +}); + +describe('SocketIOMCPTransportImpl — updateSocket', () => { + it('switches to the new socket and removes old listeners', () => { + const socket1 = makeSocket(); + const socket2 = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket1 as never); + + transport.updateSocket(socket2 as never); + + // Old socket should no longer have the mcp:response listener. + const oldHandlers = socket1._handlers.get('mcp:response') ?? []; + expect(oldHandlers).toHaveLength(0); + + // New socket should have it. + const newHandlers = socket2._handlers.get('mcp:response') ?? []; + expect(newHandlers.length).toBeGreaterThan(0); + }); + + it('sets socket to undefined when called with null', () => { + const socket = makeSocket(); + const transport = new SocketIOMCPTransportImpl(socket as never); + transport.updateSocket(null); + expect(transport.connected).toBe(false); + }); +}); diff --git a/app/src/lib/mcp/validation.test.ts b/app/src/lib/mcp/validation.test.ts new file mode 100644 index 000000000..047ebb98c --- /dev/null +++ b/app/src/lib/mcp/validation.test.ts @@ -0,0 +1,121 @@ +/** + * Unit tests for MCP validation utilities + */ +import { describe, expect, it } from 'vitest'; + +import { + validateId, + validateIdList, + validateOptionalId, + validatePositiveInt, + ValidationError, +} from './validation'; + +describe('ValidationError', () => { + it('is an instance of Error with name ValidationError', () => { + const err = new ValidationError('bad input'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('ValidationError'); + expect(err.message).toBe('bad input'); + }); +}); + +describe('validateId', () => { + it('accepts a positive integer', () => { + expect(validateId(123, 'chat_id')).toBe(123); + }); + + it('accepts a negative integer', () => { + expect(validateId(-100, 'chat_id')).toBe(-100); + }); + + it('accepts a numeric string and returns the parsed integer', () => { + expect(validateId('456', 'user_id')).toBe(456); + }); + + it('accepts a bare username string and prepends @', () => { + expect(validateId('username', 'user_id')).toBe('@username'); + }); + + it('accepts a username string that already starts with @', () => { + expect(validateId('@username', 'user_id')).toBe('@username'); + }); + + it('throws ValidationError for a non-integer number', () => { + expect(() => validateId(3.14, 'chat_id')).toThrow(ValidationError); + }); + + it('throws ValidationError for an invalid string that is not a number or username', () => { + expect(() => validateId('!!!', 'chat_id')).toThrow(ValidationError); + }); + + it('throws ValidationError for null', () => { + expect(() => validateId(null, 'chat_id')).toThrow(ValidationError); + }); + + it('throws ValidationError for boolean', () => { + expect(() => validateId(true, 'chat_id')).toThrow(ValidationError); + }); +}); + +describe('validateIdList', () => { + it('validates an array of integers', () => { + expect(validateIdList([1, 2, 3], 'ids')).toEqual([1, 2, 3]); + }); + + it('validates a mixed array of integers and usernames', () => { + expect(validateIdList([42, 'alice'], 'ids')).toEqual([42, '@alice']); + }); + + it('throws ValidationError when value is not an array', () => { + expect(() => validateIdList('not-an-array', 'ids')).toThrow(ValidationError); + }); + + it('throws ValidationError when an element is invalid', () => { + expect(() => validateIdList([1, null], 'ids')).toThrow(ValidationError); + }); +}); + +describe('validatePositiveInt', () => { + it('accepts a positive integer', () => { + expect(validatePositiveInt(5, 'msg_id')).toBe(5); + }); + + it('accepts a positive integer string', () => { + expect(validatePositiveInt('10', 'msg_id')).toBe(10); + }); + + it('throws ValidationError for zero', () => { + expect(() => validatePositiveInt(0, 'msg_id')).toThrow(ValidationError); + }); + + it('throws ValidationError for a negative number', () => { + expect(() => validatePositiveInt(-1, 'msg_id')).toThrow(ValidationError); + }); + + it('throws ValidationError for a float', () => { + expect(() => validatePositiveInt(1.5, 'msg_id')).toThrow(ValidationError); + }); + + it('throws ValidationError for a non-numeric string', () => { + expect(() => validatePositiveInt('abc', 'msg_id')).toThrow(ValidationError); + }); +}); + +describe('validateOptionalId', () => { + it('returns undefined for undefined input', () => { + expect(validateOptionalId(undefined, 'chat_id')).toBeUndefined(); + }); + + it('returns undefined for null input', () => { + expect(validateOptionalId(null, 'chat_id')).toBeUndefined(); + }); + + it('delegates to validateId for a present value', () => { + expect(validateOptionalId(99, 'chat_id')).toBe(99); + }); + + it('throws ValidationError when the present value is invalid', () => { + expect(() => validateOptionalId(true, 'chat_id')).toThrow(ValidationError); + }); +}); diff --git a/app/src/services/api/tunnelsApi.test.ts b/app/src/services/api/tunnelsApi.test.ts new file mode 100644 index 000000000..c99dfad59 --- /dev/null +++ b/app/src/services/api/tunnelsApi.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCallCoreCommand = vi.fn(); + +vi.mock('../coreCommandClient', () => ({ + callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args), +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeTunnel(id: string) { + return { + id, + uuid: `uuid-${id}`, + name: `Tunnel ${id}`, + description: 'A test tunnel', + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('tunnelsApi.createTunnel', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('calls webhooks_create_tunnel with the request body', async () => { + const tunnel = makeTunnel('t-1'); + mockCallCoreCommand.mockResolvedValueOnce(tunnel); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.createTunnel({ name: 'My Tunnel', description: 'desc' }); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_create_tunnel', { + name: 'My Tunnel', + description: 'desc', + }); + expect(result.id).toBe('t-1'); + expect(result.name).toBe('Tunnel t-1'); + }); + + it('creates a tunnel without an optional description', async () => { + mockCallCoreCommand.mockResolvedValueOnce(makeTunnel('t-2')); + + const { tunnelsApi } = await import('./tunnelsApi'); + await tunnelsApi.createTunnel({ name: 'No Desc' }); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_create_tunnel', { + name: 'No Desc', + }); + }); + + it('propagates rejection from callCoreCommand', async () => { + mockCallCoreCommand.mockRejectedValueOnce(new Error('create failed')); + + const { tunnelsApi } = await import('./tunnelsApi'); + await expect(tunnelsApi.createTunnel({ name: 'bad' })).rejects.toThrow('create failed'); + }); +}); + +describe('tunnelsApi.getTunnels', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('calls webhooks_list_tunnels with no params', async () => { + const tunnels = [makeTunnel('t-1'), makeTunnel('t-2')]; + mockCallCoreCommand.mockResolvedValueOnce(tunnels); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.getTunnels(); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_list_tunnels'); + expect(result).toHaveLength(2); + }); + + it('returns empty array when no tunnels exist', async () => { + mockCallCoreCommand.mockResolvedValueOnce([]); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.getTunnels(); + + expect(result).toEqual([]); + }); +}); + +describe('tunnelsApi.getBandwidthUsage', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('calls webhooks_get_bandwidth and returns budget info', async () => { + mockCallCoreCommand.mockResolvedValueOnce({ remainingBudgetUsd: 4.5 }); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.getBandwidthUsage(); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_get_bandwidth'); + expect(result.remainingBudgetUsd).toBe(4.5); + }); +}); + +describe('tunnelsApi.getTunnel', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('calls webhooks_get_tunnel with the tunnel ID', async () => { + mockCallCoreCommand.mockResolvedValueOnce(makeTunnel('t-99')); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.getTunnel('t-99'); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_get_tunnel', { + id: 't-99', + }); + expect(result.id).toBe('t-99'); + }); + + it('propagates rejection when tunnel is not found', async () => { + mockCallCoreCommand.mockRejectedValueOnce(new Error('not found')); + + const { tunnelsApi } = await import('./tunnelsApi'); + await expect(tunnelsApi.getTunnel('missing')).rejects.toThrow('not found'); + }); +}); + +describe('tunnelsApi.updateTunnel', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('calls webhooks_update_tunnel merging id with body fields', async () => { + const updated = { ...makeTunnel('t-1'), name: 'Renamed' }; + mockCallCoreCommand.mockResolvedValueOnce(updated); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.updateTunnel('t-1', { name: 'Renamed' }); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_update_tunnel', { + id: 't-1', + name: 'Renamed', + }); + expect(result.name).toBe('Renamed'); + }); + + it('can set isActive to false', async () => { + const deactivated = { ...makeTunnel('t-2'), isActive: false }; + mockCallCoreCommand.mockResolvedValueOnce(deactivated); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.updateTunnel('t-2', { isActive: false }); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_update_tunnel', { + id: 't-2', + isActive: false, + }); + expect(result.isActive).toBe(false); + }); + + it('propagates rejection from callCoreCommand', async () => { + mockCallCoreCommand.mockRejectedValueOnce(new Error('update failed')); + + const { tunnelsApi } = await import('./tunnelsApi'); + await expect(tunnelsApi.updateTunnel('t-bad', { name: 'x' })).rejects.toThrow('update failed'); + }); +}); + +describe('tunnelsApi.deleteTunnel', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('calls webhooks_delete_tunnel with the tunnel ID', async () => { + mockCallCoreCommand.mockResolvedValueOnce(undefined); + + const { tunnelsApi } = await import('./tunnelsApi'); + await tunnelsApi.deleteTunnel('t-1'); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.webhooks_delete_tunnel', { + id: 't-1', + }); + }); + + it('resolves without a return value on success', async () => { + mockCallCoreCommand.mockResolvedValueOnce(undefined); + + const { tunnelsApi } = await import('./tunnelsApi'); + const result = await tunnelsApi.deleteTunnel('t-1'); + expect(result).toBeUndefined(); + }); + + it('propagates rejection from callCoreCommand', async () => { + mockCallCoreCommand.mockRejectedValueOnce(new Error('delete failed')); + + const { tunnelsApi } = await import('./tunnelsApi'); + await expect(tunnelsApi.deleteTunnel('t-bad')).rejects.toThrow('delete failed'); + }); +}); + +describe('tunnelsApi.ingressUrl', () => { + it('builds the correct ingress URL from backend URL and tunnel UUID', async () => { + const { tunnelsApi } = await import('./tunnelsApi'); + const url = tunnelsApi.ingressUrl('https://api.example.com', 'uuid-abc'); + expect(url).toBe('https://api.example.com/webhooks/ingress/uuid-abc'); + }); + + it('strips trailing slash from backendUrl before building the URL', async () => { + const { tunnelsApi } = await import('./tunnelsApi'); + const url = tunnelsApi.ingressUrl('https://api.example.com/', 'uuid-xyz'); + expect(url).toBe('https://api.example.com/webhooks/ingress/uuid-xyz'); + }); + + it('works with localhost URLs', async () => { + const { tunnelsApi } = await import('./tunnelsApi'); + const url = tunnelsApi.ingressUrl('http://localhost:5005', 'test-uuid'); + expect(url).toBe('http://localhost:5005/webhooks/ingress/test-uuid'); + }); +}); diff --git a/app/src/services/coreCommandClient.test.ts b/app/src/services/coreCommandClient.test.ts new file mode 100644 index 000000000..00286a6d0 --- /dev/null +++ b/app/src/services/coreCommandClient.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCallCoreRpc = vi.fn(); + +vi.mock('./coreRpcClient', () => ({ + callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args), +})); + +describe('coreCommandClient', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('returns result from a successful CoreCommandResponse', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: 42, logs: [] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + const out = await callCoreCommand('openhuman.some_method'); + + expect(out).toBe(42); + }); + + it('forwards method name to callCoreRpc', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: 'ok', logs: [] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + await callCoreCommand('openhuman.webhooks_list_tunnels'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.webhooks_list_tunnels', + params: undefined, + }); + }); + + it('forwards params to callCoreRpc when provided', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: { id: 't1' }, logs: [] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + const params = { id: 'tunnel-99', name: 'my-hook' }; + await callCoreCommand('openhuman.webhooks_update_tunnel', params); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.webhooks_update_tunnel', + params, + }); + }); + + it('returns object result intact', async () => { + const payload = { tunnelId: 'abc', active: true }; + mockCallCoreRpc.mockResolvedValueOnce({ result: payload, logs: ['created'] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + const out = await callCoreCommand('openhuman.webhooks_create_tunnel', { + name: 'test', + }); + + expect(out).toEqual(payload); + }); + + it('propagates rejection from callCoreRpc', async () => { + mockCallCoreRpc.mockRejectedValueOnce(new Error('core offline')); + + const { callCoreCommand } = await import('./coreCommandClient'); + await expect(callCoreCommand('openhuman.config_get')).rejects.toThrow('core offline'); + }); + + it('handles undefined params gracefully (does not throw)', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: [] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + await expect(callCoreCommand('openhuman.app_state_snapshot')).resolves.toBeNull(); + }); + + it('returns null result when core responds with null', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: [] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + const out = await callCoreCommand('openhuman.some_nullable_method'); + expect(out).toBeNull(); + }); + + it('returns array result when core responds with an array', async () => { + const list = [{ id: '1' }, { id: '2' }]; + mockCallCoreRpc.mockResolvedValueOnce({ result: list, logs: [] }); + + const { callCoreCommand } = await import('./coreCommandClient'); + const out = await callCoreCommand('openhuman.list_items'); + expect(out).toEqual(list); + expect(out).toHaveLength(2); + }); +}); diff --git a/app/src/services/coreStateApi.test.ts b/app/src/services/coreStateApi.test.ts new file mode 100644 index 000000000..fcfa0c05e --- /dev/null +++ b/app/src/services/coreStateApi.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCallCoreRpc = vi.fn(); + +vi.mock('./coreRpcClient', () => ({ + callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args), +})); + +// Minimal fixtures ----------------------------------------------------------------- + +function makeSnapshotResult(overrides: Record = {}) { + return { + auth: { isAuthenticated: true, userId: 'u-1', user: null, profileId: 'p-1' }, + sessionToken: 'tok-abc', + currentUser: null, + onboardingCompleted: false, + analyticsEnabled: true, + localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null }, + runtime: { screenIntelligence: {}, localAi: {}, autocomplete: {}, service: {} }, + ...overrides, + }; +} + +function makeTeam(id: string) { + return { + team: { + _id: id, + name: `Team ${id}`, + slug: id, + createdBy: 'u-0', + isPersonal: false, + maxMembers: 10, + subscription: { plan: 'FREE' as const, hasActiveSubscription: false }, + usage: { dailyTokenLimit: 0, remainingTokens: 0, activeSessionCount: 0 }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + role: 'MEMBER' as const, + }; +} + +function makeMember(id: string) { + return { + _id: id, + user: { _id: `u-${id}` }, + role: 'MEMBER' as const, + joinedAt: '2026-01-01T00:00:00Z', + }; +} + +function makeInvite(id: string) { + return { + _id: id, + code: `code-${id}`, + createdBy: 'u-0', + expiresAt: '2027-01-01T00:00:00Z', + maxUses: 10, + currentUses: 0, + usageHistory: [], + }; +} + +// Tests ---------------------------------------------------------------------------- + +describe('coreStateApi.fetchCoreAppSnapshot', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('calls the correct RPC method', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: makeSnapshotResult() }); + + const { fetchCoreAppSnapshot } = await import('./coreStateApi'); + await fetchCoreAppSnapshot(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.app_state_snapshot' }); + }); + + it('returns the inner result from the RPC envelope', async () => { + const snapshot = makeSnapshotResult({ sessionToken: 'tok-xyz' }); + mockCallCoreRpc.mockResolvedValueOnce({ result: snapshot }); + + const { fetchCoreAppSnapshot } = await import('./coreStateApi'); + const out = await fetchCoreAppSnapshot(); + + expect(out.sessionToken).toBe('tok-xyz'); + expect(out.auth.isAuthenticated).toBe(true); + expect(out.auth.userId).toBe('u-1'); + }); + + it('propagates rejection from callCoreRpc', async () => { + mockCallCoreRpc.mockRejectedValueOnce(new Error('snapshot failed')); + + const { fetchCoreAppSnapshot } = await import('./coreStateApi'); + await expect(fetchCoreAppSnapshot()).rejects.toThrow('snapshot failed'); + }); +}); + +describe('coreStateApi.updateCoreLocalState', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('calls the correct RPC method with params', async () => { + mockCallCoreRpc.mockResolvedValueOnce({}); + + const { updateCoreLocalState } = await import('./coreStateApi'); + const params = { encryptionKey: 'key-123', primaryWalletAddress: '0xABCD' }; + await updateCoreLocalState(params); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.app_state_update_local_state', + params, + }); + }); + + it('resolves without a return value on success', async () => { + mockCallCoreRpc.mockResolvedValueOnce({}); + + const { updateCoreLocalState } = await import('./coreStateApi'); + const result = await updateCoreLocalState({ encryptionKey: null }); + expect(result).toBeUndefined(); + }); + + it('passes null fields correctly to the RPC', async () => { + mockCallCoreRpc.mockResolvedValueOnce({}); + + const { updateCoreLocalState } = await import('./coreStateApi'); + await updateCoreLocalState({ + encryptionKey: null, + primaryWalletAddress: null, + onboardingTasks: null, + }); + + const call = mockCallCoreRpc.mock.calls[0][0] as { params: unknown }; + expect(call.params).toEqual({ + encryptionKey: null, + primaryWalletAddress: null, + onboardingTasks: null, + }); + }); + + it('propagates rejection from callCoreRpc', async () => { + mockCallCoreRpc.mockRejectedValueOnce(new Error('update rejected')); + + const { updateCoreLocalState } = await import('./coreStateApi'); + await expect(updateCoreLocalState({})).rejects.toThrow('update rejected'); + }); +}); + +describe('coreStateApi.listTeams', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('calls team_list_teams RPC and returns teams array', async () => { + const teams = [makeTeam('t-1'), makeTeam('t-2')]; + mockCallCoreRpc.mockResolvedValueOnce({ result: teams }); + + const { listTeams } = await import('./coreStateApi'); + const out = await listTeams(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.team_list_teams' }); + expect(out).toHaveLength(2); + expect(out[0].team._id).toBe('t-1'); + }); + + it('returns empty array when no teams exist', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: [] }); + + const { listTeams } = await import('./coreStateApi'); + const out = await listTeams(); + + expect(out).toEqual([]); + }); +}); + +describe('coreStateApi.getTeamMembers', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('calls team_list_members with teamId param', async () => { + const members = [makeMember('m-1')]; + mockCallCoreRpc.mockResolvedValueOnce({ result: members }); + + const { getTeamMembers } = await import('./coreStateApi'); + const out = await getTeamMembers('t-1'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.team_list_members', + params: { teamId: 't-1' }, + }); + expect(out[0]._id).toBe('m-1'); + }); + + it('returns the inner result array', async () => { + const members = [makeMember('a'), makeMember('b')]; + mockCallCoreRpc.mockResolvedValueOnce({ result: members }); + + const { getTeamMembers } = await import('./coreStateApi'); + const out = await getTeamMembers('t-99'); + + expect(out).toHaveLength(2); + }); +}); + +describe('coreStateApi.getTeamInvites', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('calls team_list_invites with teamId param', async () => { + const invites = [makeInvite('inv-1')]; + mockCallCoreRpc.mockResolvedValueOnce({ result: invites }); + + const { getTeamInvites } = await import('./coreStateApi'); + const out = await getTeamInvites('t-1'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.team_list_invites', + params: { teamId: 't-1' }, + }); + expect(out[0]._id).toBe('inv-1'); + }); + + it('returns empty array when no invites exist', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: [] }); + + const { getTeamInvites } = await import('./coreStateApi'); + const out = await getTeamInvites('t-empty'); + + expect(out).toEqual([]); + }); +}); diff --git a/app/src/services/errorReportQueue.test.ts b/app/src/services/errorReportQueue.test.ts new file mode 100644 index 000000000..e1559b2c0 --- /dev/null +++ b/app/src/services/errorReportQueue.test.ts @@ -0,0 +1,361 @@ +/** + * @vitest-environment jsdom + * + * Tests for errorReportQueue — module-level error queue with no React/Redux/Sentry deps. + * + * Because the module holds mutable singleton state, we re-import it fresh in each + * describe block via `vi.resetModules()` to isolate tests from each other. + * + * The source module calls `initGlobalListeners()` at load time, which reads `window` + * — the explicit environment directive ensures jsdom is active even when Vitest picks + * a different default for a given run. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// setup.ts already mocks @sentry/react and ../utils/config globally. +// We rely on those global mocks here. + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeReport( + id: string, + overrides: Partial<{ + title: string; + message: string; + source: 'react' | 'global' | 'manual'; + timestamp: number; + }> = {} +) { + return { + id, + timestamp: overrides.timestamp ?? Date.now(), + source: overrides.source ?? ('manual' as const), + title: overrides.title ?? `Error ${id}`, + message: overrides.message ?? `Message for ${id}`, + sentryEvent: null, + }; +} + +// --------------------------------------------------------------------------- +// enqueueError / getErrors / dequeueError +// --------------------------------------------------------------------------- + +describe('errorReportQueue — enqueue / dequeue / getErrors', () => { + let eq: typeof import('./errorReportQueue'); + + beforeEach(async () => { + vi.resetModules(); + eq = await import('./errorReportQueue'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts with an empty queue', () => { + expect(eq.getErrors()).toEqual([]); + }); + + it('adds a report and returns it from getErrors', () => { + const report = makeReport('r1'); + eq.enqueueError(report); + + const queue = eq.getErrors(); + expect(queue).toHaveLength(1); + expect(queue[0].id).toBe('r1'); + }); + + it('removes a report via dequeueError', () => { + eq.enqueueError(makeReport('r1')); + eq.enqueueError(makeReport('r2')); + eq.dequeueError('r1'); + + const queue = eq.getErrors(); + expect(queue).toHaveLength(1); + expect(queue[0].id).toBe('r2'); + }); + + it('is a no-op when dequeuing a non-existent ID', () => { + eq.enqueueError(makeReport('r1')); + eq.dequeueError('does-not-exist'); + + expect(eq.getErrors()).toHaveLength(1); + }); + + it('preserves insertion order', () => { + eq.enqueueError(makeReport('r1')); + eq.enqueueError(makeReport('r2')); + eq.enqueueError(makeReport('r3')); + + const ids = eq.getErrors().map(r => r.id); + expect(ids).toEqual(['r1', 'r2', 'r3']); + }); + + it('caps queue at 10 items, keeping the most recent', () => { + for (let i = 0; i < 12; i++) { + // Each report has a unique title+message to avoid dedup + eq.enqueueError(makeReport(`r${i}`, { title: `T${i}`, message: `M${i}` })); + } + + const queue = eq.getErrors(); + expect(queue).toHaveLength(10); + // Oldest two should have been dropped + expect(queue[0].id).toBe('r2'); + expect(queue[9].id).toBe('r11'); + }); +}); + +// --------------------------------------------------------------------------- +// Deduplication +// --------------------------------------------------------------------------- + +describe('errorReportQueue — deduplication', () => { + let eq: typeof import('./errorReportQueue'); + + beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + eq = await import('./errorReportQueue'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('suppresses a duplicate error within the dedup window (2 s)', () => { + const base = makeReport('r1', { title: 'Same', message: 'Same' }); + eq.enqueueError(base); + // Same title + message but different ID — should be deduped + eq.enqueueError({ ...base, id: 'r1-dup' }); + + expect(eq.getErrors()).toHaveLength(1); + }); + + it('allows the same error again after the dedup window expires', () => { + const base = makeReport('r1', { title: 'Same', message: 'Same' }); + eq.enqueueError(base); + + // Advance past the 2000 ms DEDUP_WINDOW_MS + vi.advanceTimersByTime(2001); + + eq.enqueueError({ ...base, id: 'r1-after' }); + expect(eq.getErrors()).toHaveLength(2); + }); + + it('allows distinct errors (different title/message) within the dedup window', () => { + eq.enqueueError(makeReport('r1', { title: 'A', message: 'a' })); + eq.enqueueError(makeReport('r2', { title: 'B', message: 'b' })); + + expect(eq.getErrors()).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// subscribe / notify +// --------------------------------------------------------------------------- + +describe('errorReportQueue — subscribe', () => { + let eq: typeof import('./errorReportQueue'); + + beforeEach(async () => { + vi.resetModules(); + eq = await import('./errorReportQueue'); + }); + + it('calls subscriber when a report is enqueued', () => { + const cb = vi.fn(); + eq.subscribe(cb); + eq.enqueueError(makeReport('r1')); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('calls subscriber when a report is dequeued', () => { + eq.enqueueError(makeReport('r1')); + const cb = vi.fn(); + eq.subscribe(cb); + eq.dequeueError('r1'); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('does not call subscriber after unsubscribe', () => { + const cb = vi.fn(); + const unsub = eq.subscribe(cb); + unsub(); + eq.enqueueError(makeReport('r1')); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('supports multiple simultaneous subscribers', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + eq.subscribe(cb1); + eq.subscribe(cb2); + eq.enqueueError(makeReport('r1')); + + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('continues notifying other subscribers if one throws', () => { + const bad = vi.fn(() => { + throw new Error('subscriber boom'); + }); + const good = vi.fn(); + eq.subscribe(bad); + eq.subscribe(good); + + expect(() => eq.enqueueError(makeReport('r1'))).not.toThrow(); + expect(good).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// tagErrorSource +// --------------------------------------------------------------------------- + +describe('errorReportQueue — tagErrorSource', () => { + let eq: typeof import('./errorReportQueue'); + + beforeEach(async () => { + vi.resetModules(); + eq = await import('./errorReportQueue'); + }); + + it('updates source on a queued report matched by sentryEvent.event_id', () => { + const report = { + ...makeReport('r1'), + source: 'global' as const, + sentryEvent: { + event_id: 'sentry-abc', + timestamp: Date.now() / 1000, + platform: 'javascript', + environment: 'test', + }, + }; + eq.enqueueError(report); + eq.tagErrorSource('sentry-abc', 'react', ''); + + const updated = eq.getErrors().find(r => r.id === 'r1'); + expect(updated?.source).toBe('react'); + expect(updated?.componentStack).toBe(''); + }); + + it('is a no-op when event_id is undefined', () => { + eq.enqueueError(makeReport('r1')); + expect(() => eq.tagErrorSource(undefined, 'react')).not.toThrow(); + expect(eq.getErrors()[0].source).toBe('manual'); + }); + + it('is a no-op when event_id does not match any queued report', () => { + eq.enqueueError(makeReport('r1')); + expect(() => eq.tagErrorSource('unknown-id', 'react')).not.toThrow(); + expect(eq.getErrors()[0].source).toBe('manual'); + }); +}); + +// --------------------------------------------------------------------------- +// registerSentrySender / sendToSentry +// --------------------------------------------------------------------------- + +describe('errorReportQueue — sendToSentry', () => { + let eq: typeof import('./errorReportQueue'); + + beforeEach(async () => { + vi.resetModules(); + eq = await import('./errorReportQueue'); + }); + + it('returns false when no sender is registered', () => { + const report = { + ...makeReport('r1'), + sentryEvent: { + event_id: 'evt-1', + timestamp: Date.now() / 1000, + platform: 'javascript', + environment: 'test', + }, + }; + eq.enqueueError(report); + + expect(eq.sendToSentry(report)).toBe(false); + }); + + it('returns false when sentryEvent is null', () => { + const sender = vi.fn(); + const report = makeReport('r1'); + eq.enqueueError(report); + eq.registerSentrySender(sender); + + expect(eq.sendToSentry(report)).toBe(false); + expect(sender).not.toHaveBeenCalled(); + }); + + it('calls the registered sender and removes the report on success', () => { + const sender = vi.fn(); + const sentryEvent = { + event_id: 'evt-2', + timestamp: Date.now() / 1000, + platform: 'javascript', + environment: 'test', + }; + const report = { ...makeReport('r2'), sentryEvent }; + eq.enqueueError(report); + eq.registerSentrySender(sender); + + const result = eq.sendToSentry(report); + + expect(result).toBe(true); + expect(sender).toHaveBeenCalledWith(sentryEvent); + expect(eq.getErrors().find(r => r.id === 'r2')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildManualSentryEvent +// --------------------------------------------------------------------------- + +describe('errorReportQueue — buildManualSentryEvent', () => { + let eq: typeof import('./errorReportQueue'); + + beforeEach(async () => { + vi.resetModules(); + eq = await import('./errorReportQueue'); + }); + + it('creates an event with the correct error type and value', () => { + const evt = eq.buildManualSentryEvent({ type: 'TypeError', value: 'bad input' }); + + expect(evt.exception?.values[0].type).toBe('TypeError'); + expect(evt.exception?.values[0].value).toBe('bad input'); + }); + + it('sets platform to javascript', () => { + const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }); + expect(evt.platform).toBe('javascript'); + }); + + it('includes optional tags when provided', () => { + const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }, { context: 'chat' }); + expect(evt.tags).toEqual({ context: 'chat' }); + }); + + it('generates a non-empty event_id', () => { + const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }); + expect(evt.event_id).toBeTruthy(); + expect(typeof evt.event_id).toBe('string'); + }); + + it('sets timestamp as a unix seconds value (numeric)', () => { + const before = Date.now() / 1000; + const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }); + const after = Date.now() / 1000; + + expect(evt.timestamp).toBeGreaterThanOrEqual(before); + expect(evt.timestamp).toBeLessThanOrEqual(after); + }); +}); diff --git a/app/src/utils/cryptoKeys.test.ts b/app/src/utils/cryptoKeys.test.ts new file mode 100644 index 000000000..de1c932d8 --- /dev/null +++ b/app/src/utils/cryptoKeys.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveAesKeyFromMnemonic, + deriveEvmAddressFromMnemonic, + generateMnemonicPhrase, + MNEMONIC_GENERATE_WORD_COUNT, + validateMnemonicPhrase, +} from './cryptoKeys'; + +// Known-good 12-word BIP39 mnemonic for deterministic assertions. +const KNOWN_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +describe('MNEMONIC_GENERATE_WORD_COUNT', () => { + it('is 12', () => { + expect(MNEMONIC_GENERATE_WORD_COUNT).toBe(12); + }); +}); + +describe('generateMnemonicPhrase', () => { + it('returns a 12-word phrase', () => { + const phrase = generateMnemonicPhrase(); + expect(phrase.trim().split(/\s+/)).toHaveLength(12); + }); + + it('returns a valid BIP39 mnemonic', () => { + const phrase = generateMnemonicPhrase(); + expect(validateMnemonicPhrase(phrase)).toBe(true); + }); + + it('produces a different phrase each call', () => { + const a = generateMnemonicPhrase(); + const b = generateMnemonicPhrase(); + // Astronomically unlikely to collide; guards against a no-op implementation. + expect(a).not.toBe(b); + }); +}); + +describe('validateMnemonicPhrase', () => { + it('returns true for the known valid mnemonic', () => { + expect(validateMnemonicPhrase(KNOWN_MNEMONIC)).toBe(true); + }); + + it('returns false for an empty string', () => { + expect(validateMnemonicPhrase('')).toBe(false); + }); + + it('returns false for a single random word', () => { + expect(validateMnemonicPhrase('abandon')).toBe(false); + }); + + it('returns false for 12 non-BIP39 words', () => { + expect( + validateMnemonicPhrase('foo bar baz qux quux corge grault garply waldo fred plugh xyzzy') + ).toBe(false); + }); + + it('returns false for an otherwise valid phrase with one word replaced by a non-wordlist word', () => { + const tampered = KNOWN_MNEMONIC.replace('abandon', 'xxxxxxxx'); + expect(validateMnemonicPhrase(tampered)).toBe(false); + }); +}); + +describe('deriveAesKeyFromMnemonic', () => { + it('returns a 64-character hex string (256-bit key)', () => { + const key = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC); + expect(key).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is deterministic for the same mnemonic', () => { + const key1 = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC); + const key2 = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC); + expect(key1).toBe(key2); + }); + + it('produces a different key for a different mnemonic', () => { + const other = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'; + expect(validateMnemonicPhrase(other)).toBe(true); + const keyA = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC); + const keyB = deriveAesKeyFromMnemonic(other); + expect(keyA).not.toBe(keyB); + }); + + it('returns a fixed known value for the all-abandon mnemonic', () => { + // Pinned output for salt='openhuman-aes-key-v1', PBKDF2-SHA256, c=100000, dkLen=32. + // If this assertion fails, the KDF parameters or salt have changed — update intentionally. + const key = deriveAesKeyFromMnemonic(KNOWN_MNEMONIC); + expect(key).toBe('dce707ee483afb0a70cb2e076295f9f914e0c62cc097895eabda1c0c1f2f0cb1'); + }); +}); + +describe('deriveEvmAddressFromMnemonic', () => { + it('returns a 0x-prefixed 42-character string', () => { + const address = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC); + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('is deterministic for the same mnemonic', () => { + const addr1 = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC); + const addr2 = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC); + expect(addr1).toBe(addr2); + }); + + it('returns the well-known address for the all-abandon mnemonic', () => { + // MetaMask / BIP44 m/44'/60'/0'/0/0 for all-abandon is a stable known address. + // Pinned in EIP-55 checksummed form — validates both identity and checksum casing. + const address = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC); + expect(address).toBe('0x9858EfFD232B4033E47d90003D41EC34EcaEda94'); + }); + + it('returns a different address for a different mnemonic', () => { + const other = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'; + const addrA = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC); + const addrB = deriveEvmAddressFromMnemonic(other); + expect(addrA).not.toBe(addrB); + }); + + it('produces a checksummed address (EIP-55)', () => { + const address = deriveEvmAddressFromMnemonic(KNOWN_MNEMONIC); + // At least some characters must be uppercase (otherwise it would just be lowercase hex). + // EIP-55 checksummed addresses contain mixed case by design. + const hex = address.slice(2); + // Not all-lowercase and not all-uppercase → mixed casing applied. + const hasUpper = hex !== hex.toLowerCase(); + const hasLower = hex !== hex.toUpperCase(); + expect(hasUpper && hasLower).toBe(true); + }); +}); diff --git a/app/src/utils/withTimeout.test.ts b/app/src/utils/withTimeout.test.ts new file mode 100644 index 000000000..d3b5578a2 --- /dev/null +++ b/app/src/utils/withTimeout.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { toolExecutionTimeoutMsFromEnv, withTimeout } from './withTimeout'; + +// Provide TOOL_TIMEOUT_SECS that the global setup mock omits. +vi.mock('./config', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, TOOL_TIMEOUT_SECS: 120 }; +}); + +describe('withTimeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves with the promise value when it settles before the timeout', async () => { + const promise = Promise.resolve('ok'); + const result = await withTimeout(promise, 5000, 'fast-op'); + expect(result).toBe('ok'); + }); + + it('rejects with a timeout error when the timeout elapses first', async () => { + // withTimeout creates an internal timeoutPromise that rejects. When + // Promise.race settles via that rejection, the internal promise itself + // has no handler yet — Node surfaces it as an unhandled rejection warning. + // We suppress it by attaching a catch to the overall race before advancing + // the timer, then asserting on the stored error. + let capturedError: unknown; + const never = new Promise(() => {}); + + const racePromise = withTimeout(never, 3000, 'slow-op').catch((e: unknown) => { + capturedError = e; + // Swallow here — we assert manually below. + }); + + await vi.advanceTimersByTimeAsync(3001); + await racePromise; // wait for the catch branch to complete + + expect(capturedError).toBeInstanceOf(Error); + expect((capturedError as Error).message).toBe('slow-op timed out after 3s'); + }); + + it('error message rounds timeout to seconds', async () => { + let capturedError: unknown; + const never = new Promise(() => {}); + + const racePromise = withTimeout(never, 2500, 'half-sec').catch((e: unknown) => { + capturedError = e; + }); + + await vi.advanceTimersByTimeAsync(3000); + await racePromise; + + expect(capturedError).toBeInstanceOf(Error); + // Math.round(2500 / 1000) === 3 + expect((capturedError as Error).message).toBe('half-sec timed out after 3s'); + }); + + it('passes through a rejection from the underlying promise', async () => { + const failing = Promise.reject(new Error('upstream failure')); + failing.catch(() => {}); // suppress premature unhandled warning + await expect(withTimeout(failing, 5000, 'fail-op')).rejects.toThrow('upstream failure'); + }); + + it('does not fire the timeout when the promise rejects quickly', async () => { + const err = new Error('quick rejection'); + const failing = Promise.reject(err); + failing.catch(() => {}); // suppress premature unhandled warning + const racePromise = withTimeout(failing, 5000, 'quick-fail'); + + await expect(racePromise).rejects.toThrow('quick rejection'); + + // Advance well past the timeout — timer should already be cleared. + await vi.advanceTimersByTimeAsync(10000); + }); + + it('bypasses the timeout when timeoutMs <= 0 and returns the raw promise', async () => { + const promise = Promise.resolve(42); + const result = await withTimeout(promise, 0, 'zero-timeout'); + expect(result).toBe(42); + }); + + it('bypasses the timeout for negative timeoutMs', async () => { + const promise = Promise.resolve('neg'); + const result = await withTimeout(promise, -1, 'negative-timeout'); + expect(result).toBe('neg'); + }); + + it('resolves with complex return types', async () => { + const payload = { id: 1, data: [true, 'hello'] }; + const result = await withTimeout(Promise.resolve(payload), 1000, 'complex'); + expect(result).toEqual(payload); + }); + + it('clears the timer when the promise resolves (no dangling timer)', async () => { + const clearSpy = vi.spyOn(globalThis, 'clearTimeout'); + await withTimeout(Promise.resolve('done'), 5000, 'cleanup-check'); + expect(clearSpy).toHaveBeenCalled(); + }); +}); + +describe('toolExecutionTimeoutMsFromEnv', () => { + it('returns a positive number of milliseconds', () => { + const ms = toolExecutionTimeoutMsFromEnv(); + expect(ms).toBeGreaterThan(0); + expect(Number.isFinite(ms)).toBe(true); + }); + + it('returns 120 000 ms matching the mocked TOOL_TIMEOUT_SECS of 120', () => { + const ms = toolExecutionTimeoutMsFromEnv(); + // TOOL_TIMEOUT_SECS is mocked to 120 by this file's vi.mock above. + expect(ms).toBe(120_000); + }); +}); diff --git a/src/openhuman/autocomplete/ops.rs b/src/openhuman/autocomplete/ops.rs index e0bdcbdd6..2de45ca65 100644 --- a/src/openhuman/autocomplete/ops.rs +++ b/src/openhuman/autocomplete/ops.rs @@ -390,3 +390,293 @@ pub async fn autocomplete_start_cli( "result": start.value, })) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── autocomplete_status ──────────────────────────────────────────────────── + // + // TODO: These tests share the process-global autocomplete::global_engine() + // singleton (via autocomplete_status / autocomplete_stop / autocomplete_start). + // They are currently stable because start() always errors on non-macOS, keeping + // the engine in an idle state. Once macOS support lands -- or if concurrent tests + // transition the engine -- races on the global state will cause flakiness. + // + // Fix when that happens: serialize engine-touching tests with a + // process-wide tokio::sync::Mutex guard (or the `serial_test` crate), or + // refactor to accept an injected engine instance instead of going through + // global_engine(). + + /// Happy path: `autocomplete_status` always succeeds and produces exactly + /// two log lines with the expected key tokens. + #[tokio::test] + async fn status_returns_outcome_with_two_log_lines() { + let outcome = autocomplete_status() + .await + .expect("autocomplete_status must not return Err"); + + assert_eq!( + outcome.logs.len(), + 2, + "expected exactly 2 log lines, got: {:?}", + outcome.logs + ); + assert!( + outcome.logs[0].contains("autocomplete status fetched"), + "first log should confirm fetch: {:?}", + outcome.logs[0] + ); + assert!( + outcome.logs[1].contains("[autocomplete] status"), + "second log should contain the structured prefix: {:?}", + outcome.logs[1] + ); + } + + /// The status payload has the expected boolean/string fields and a non-empty phase. + #[tokio::test] + async fn status_payload_has_expected_fields() { + let outcome = autocomplete_status() + .await + .expect("autocomplete_status must not return Err"); + + let status = &outcome.value; + // Phase must be a non-empty string (default is "idle"). + assert!( + !status.phase.is_empty(), + "phase must not be empty, got: {:?}", + status.phase + ); + // debounce_ms is always set to a positive value by the engine default (120 ms). + assert!( + status.debounce_ms > 0, + "debounce_ms must be positive, got {}", + status.debounce_ms + ); + } + + // ── autocomplete_stop ────────────────────────────────────────────────────── + + /// Happy path: stopping a not-yet-running engine reports `stopped: true` + /// and produces two log lines. + #[tokio::test] + async fn stop_without_reason_returns_stopped_true_and_two_logs() { + let outcome = autocomplete_stop(None) + .await + .expect("autocomplete_stop must not return Err"); + + assert!( + outcome.value.stopped, + "stopped must be true even when engine was already idle" + ); + assert_eq!( + outcome.logs.len(), + 2, + "expected 2 log lines, got: {:?}", + outcome.logs + ); + assert!( + outcome.logs[0].contains("autocomplete stopped"), + "first log should confirm stop: {:?}", + outcome.logs[0] + ); + } + + /// When a `reason` is supplied, the structured log line must include it. + #[tokio::test] + async fn stop_with_reason_includes_reason_in_log() { + let payload = Some(AutocompleteStopParams { + reason: Some("test-shutdown".to_string()), + }); + + let outcome = autocomplete_stop(payload) + .await + .expect("autocomplete_stop must not return Err"); + + let structured_log = &outcome.logs[1]; + assert!( + structured_log.contains("test-shutdown"), + "structured log must contain the supplied reason; got: {:?}", + structured_log + ); + } + + /// When no reason is supplied, the structured log line must record "none". + #[tokio::test] + async fn stop_without_reason_logs_none_as_reason() { + let outcome = autocomplete_stop(None) + .await + .expect("autocomplete_stop must not return Err"); + + let structured_log = &outcome.logs[1]; + assert!( + structured_log.contains("reason=none"), + "structured log must record reason=none when no reason is given; got: {:?}", + structured_log + ); + } + + // ── autocomplete_start (non-macOS) ───────────────────────────────────────── + + /// On Linux/Windows `autocomplete_start` must return an `Err` because + /// the engine only supports macOS. This exercises the error path of the + /// ops wrapper without needing OS accessibility permissions. + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn start_returns_err_on_non_macos() { + let result = autocomplete_start(AutocompleteStartParams { debounce_ms: None }).await; + assert!( + result.is_err(), + "autocomplete_start must fail on non-macOS; got Ok" + ); + let msg = result.unwrap_err(); + assert!( + msg.contains("macOS"), + "error message must mention macOS; got: {msg:?}" + ); + } + + // ── autocomplete_start_cli (non-spawn, non-serve path, non-macOS) ────────── + + /// The plain `autocomplete_start_cli` path (neither --spawn nor --serve) + /// propagates the engine's start error on non-macOS platforms. + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn start_cli_plain_path_returns_err_on_non_macos() { + let opts = AutocompleteStartCliOptions { + debounce_ms: None, + serve: false, + spawn: false, + }; + let result = autocomplete_start_cli(opts).await; + assert!( + result.is_err(), + "start_cli plain path must propagate start failure on non-macOS; got Ok" + ); + } + + // ── AutocompleteHistoryParams struct ────────────────────────────────────── + + /// `AutocompleteHistoryParams` with an explicit limit round-trips through + /// JSON correctly — field name and value are preserved. + #[test] + fn history_params_serialise_round_trip() { + let params = AutocompleteHistoryParams { limit: Some(7) }; + let json = serde_json::to_value(¶ms).expect("serialise ok"); + assert_eq!(json["limit"], 7); + + let back: AutocompleteHistoryParams = serde_json::from_value(json).expect("deserialise ok"); + assert_eq!(back.limit, Some(7)); + } + + /// `AutocompleteHistoryParams` with no limit serialises to JSON `null` for + /// the `limit` field. + #[test] + fn history_params_none_limit_serialises_to_null() { + let params = AutocompleteHistoryParams { limit: None }; + let json = serde_json::to_value(¶ms).expect("serialise ok"); + assert!(json["limit"].is_null()); + } + + // ── AutocompleteClearHistoryResult struct ───────────────────────────────── + + /// `AutocompleteClearHistoryResult` round-trips through JSON and the + /// `cleared` field is preserved. + #[test] + fn clear_history_result_serialise_round_trip() { + let result = AutocompleteClearHistoryResult { cleared: 42 }; + let json = serde_json::to_value(&result).expect("serialise ok"); + assert_eq!(json["cleared"], 42); + + let back: AutocompleteClearHistoryResult = + serde_json::from_value(json).expect("deserialise ok"); + assert_eq!(back.cleared, 42); + } + + // ── autocomplete_history (integration) ─────────────────────────────────── + // + // NOTE: These tests operate against the real on-disk KV store via + // MemoryClient::new_local() (resolves to default_root_openhuman_dir()). + // They are marked #[ignore] to prevent wiping a contributor's autocomplete + // history on every `cargo test` run and to avoid non-deterministic results. + // Run explicitly with: cargo test -- --ignored + + /// `autocomplete_history` against a fresh (possibly empty) local KV store + /// must succeed and produce exactly two log lines — one confirmation and + /// one structured log. The result entries count may be 0 or more. + #[tokio::test] + #[ignore = "operates on real on-disk KV store; run with --ignored to opt in"] + async fn history_returns_outcome_with_two_log_lines() { + let payload = AutocompleteHistoryParams { limit: Some(5) }; + let outcome = autocomplete_history(payload) + .await + .expect("autocomplete_history must not return Err"); + + assert_eq!( + outcome.logs.len(), + 2, + "expected exactly 2 log lines; got: {:?}", + outcome.logs + ); + assert!( + outcome.logs[0].contains("autocomplete history listed"), + "first log must confirm listing; got: {:?}", + outcome.logs[0] + ); + assert!( + outcome.logs[1].contains("requested_limit=5"), + "structured log must record requested_limit; got: {:?}", + outcome.logs[1] + ); + // entries must be a valid (possibly empty) vec + let _ = &outcome.value.entries; + } + + /// When `limit` is `None`, the default of 20 is applied and appears in the log. + #[tokio::test] + #[ignore = "operates on real on-disk KV store; run with --ignored to opt in"] + async fn history_default_limit_appears_in_log() { + let payload = AutocompleteHistoryParams { limit: None }; + let outcome = autocomplete_history(payload) + .await + .expect("autocomplete_history must not return Err"); + + assert!( + outcome.logs[1].contains("requested_limit=20"), + "default limit of 20 must appear in log; got: {:?}", + outcome.logs[1] + ); + } + + // ── autocomplete_clear_history (integration) ────────────────────────────── + + /// `autocomplete_clear_history` on an already-empty or populated store must + /// succeed, return a non-negative cleared count, and emit exactly two log lines. + #[tokio::test] + #[ignore = "operates on real on-disk KV store; run with --ignored to opt in"] + async fn clear_history_returns_outcome_with_two_log_lines() { + let outcome = autocomplete_clear_history() + .await + .expect("autocomplete_clear_history must not return Err"); + + assert_eq!( + outcome.logs.len(), + 2, + "expected exactly 2 log lines; got: {:?}", + outcome.logs + ); + assert!( + outcome.logs[0].contains("autocomplete history cleared"), + "first log must confirm clear; got: {:?}", + outcome.logs[0] + ); + assert!( + outcome.logs[1].contains("cleared_entries="), + "structured log must contain cleared_entries; got: {:?}", + outcome.logs[1] + ); + // cleared is a usize — always non-negative by type + let _ = outcome.value.cleared; + } +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 8b433867b..375b475ee 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -2278,6 +2278,147 @@ async fn notification_settings_roundtrip_and_disabled_ingest_skip() { rpc_join.abort(); } +#[tokio::test] +async fn credentials_crud_roundtrip() { + // Tests the provider-credential lifecycle over the JSON-RPC transport: + // store → list → list-filtered → remove → verify-gone + // + // Provider credentials are stored locally (auth-profiles.json) and require + // no upstream network calls, so no mock session/JWT is needed. + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + // A mock upstream is required so config validation passes and api_url is + // well-formed, even though provider-credential calls don't hit the network. + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // ── 1. store a provider credential ────────────────────────────────────── + let store = post_json_rpc( + &rpc_base, + 5001, + "openhuman.auth_store_provider_credentials", + json!({ + "provider": "openai", + "profile": "default", + "token": "sk-e2e-test-key", + "setActive": true + }), + ) + .await; + // assert_no_jsonrpc_error returns the JSON-RPC `result` field which is the + // RpcOutcome envelope: {"logs": [...], "result": { }}. + let store_outer = assert_no_jsonrpc_error(&store, "auth_store_provider_credentials"); + let store_result = store_outer.get("result").unwrap_or(store_outer); + assert_eq!( + store_result.get("provider").and_then(Value::as_str), + Some("openai"), + "stored profile should have provider=openai: {store_result}" + ); + assert_eq!( + store_result.get("profileName").and_then(Value::as_str), + Some("default"), + "stored profile should have profileName=default: {store_result}" + ); + assert_eq!( + store_result.get("hasToken").and_then(Value::as_bool), + Some(true), + "stored profile should report hasToken=true: {store_result}" + ); + + // ── 2. list all provider credentials — should find openai ─────────────── + let list_all = post_json_rpc( + &rpc_base, + 5002, + "openhuman.auth_list_provider_credentials", + json!({}), + ) + .await; + let list_outer = assert_no_jsonrpc_error(&list_all, "auth_list_provider_credentials (all)"); + let list_result = list_outer.get("result").unwrap_or(list_outer); + let profiles = list_result + .as_array() + .unwrap_or_else(|| panic!("expected array from list: {list_result}")); + assert_eq!(profiles.len(), 1, "expected exactly one stored credential"); + assert_eq!( + profiles[0].get("provider").and_then(Value::as_str), + Some("openai") + ); + + // ── 3. list filtered by provider name ─────────────────────────────────── + let list_filtered = post_json_rpc( + &rpc_base, + 5003, + "openhuman.auth_list_provider_credentials", + json!({ "provider": "openai" }), + ) + .await; + let filtered_outer = + assert_no_jsonrpc_error(&list_filtered, "auth_list_provider_credentials (filtered)"); + let filtered_result = filtered_outer.get("result").unwrap_or(filtered_outer); + let filtered_profiles = filtered_result + .as_array() + .unwrap_or_else(|| panic!("expected array from filtered list: {filtered_result}")); + assert_eq!( + filtered_profiles.len(), + 1, + "filter by openai should return exactly one entry" + ); + + // ── 4. remove the stored credential ───────────────────────────────────── + let remove = post_json_rpc( + &rpc_base, + 5004, + "openhuman.auth_remove_provider_credentials", + json!({ + "provider": "openai", + "profile": "default" + }), + ) + .await; + let remove_outer = assert_no_jsonrpc_error(&remove, "auth_remove_provider_credentials"); + let remove_result = remove_outer.get("result").unwrap_or(remove_outer); + assert_eq!( + remove_result.get("removed").and_then(Value::as_bool), + Some(true), + "remove should report removed=true: {remove_result}" + ); + + // ── 5. verify the credential is gone ──────────────────────────────────── + let list_after = post_json_rpc( + &rpc_base, + 5005, + "openhuman.auth_list_provider_credentials", + json!({}), + ) + .await; + let after_outer = + assert_no_jsonrpc_error(&list_after, "auth_list_provider_credentials (after remove)"); + let after_result = after_outer.get("result").unwrap_or(after_outer); + let after_profiles = after_result + .as_array() + .unwrap_or_else(|| panic!("expected array after remove: {after_result}")); + assert!( + after_profiles.is_empty(), + "credentials list should be empty after remove, got {after_profiles:?}" + ); + + mock_join.abort(); + rpc_join.abort(); +} + /// End-to-end coverage for `openhuman.skills_uninstall`. /// /// Validates that the RPC method is registered, wire-decodes