diff --git a/app/src/pages/Welcome.tsx b/app/src/pages/Welcome.tsx index 952b04dd6..cf192ca12 100644 --- a/app/src/pages/Welcome.tsx +++ b/app/src/pages/Welcome.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import OAuthProviderButton from '../components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../components/oauth/providerConfigs'; import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas'; -import { clearCoreRpcUrlCache } from '../services/coreRpcClient'; +import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../services/coreRpcClient'; import { useDeepLinkAuthState } from '../store/deepLinkAuthState'; import { clearStoredRpcUrl, @@ -65,11 +65,7 @@ const Welcome = () => { setRpcUrlError(null); try { - const response = await fetch(normalized, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'openhuman.ping', params: {} }), - }); + const response = await testCoreRpcConnection(normalized); if (response.ok || response.status === 405) { setSaveSuccess(true); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 776b19d0d..00347f9d3 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -421,4 +421,76 @@ describe('coreRpcClient', () => { expect(tokenCalls).toBe(1); expect(fetch).not.toHaveBeenCalled(); }); + + describe('testCoreRpcConnection', () => { + test('POSTs an openhuman.ping JSON-RPC envelope to the supplied URL', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(false); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + await testCoreRpcConnection('http://example.test:7788/rpc'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://example.test:7788/rpc'); + const requestInit = init as RequestInit; + expect(requestInit.method).toBe('POST'); + expect(JSON.parse(requestInit.body as string)).toMatchObject({ + jsonrpc: '2.0', + id: 1, + method: 'openhuman.ping', + params: {}, + }); + }); + + test('omits Authorization header when no bearer token is available (non-Tauri)', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(false); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + await testCoreRpcConnection('http://example.test:7788/rpc'); + + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const headers = requestInit.headers as Record; + expect(headers).toMatchObject({ 'Content-Type': 'application/json' }); + expect(headers).not.toHaveProperty('Authorization'); + }); + + test('attaches Authorization: Bearer when the Tauri bearer token resolves', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_token') return 'deadbeef'; + throw new Error(`unexpected command: ${cmd}`); + }); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + await testCoreRpcConnection('http://example.test:7788/rpc'); + + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const headers = requestInit.headers as Record; + expect(headers.Authorization).toBe('Bearer deadbeef'); + expect(headers['Content-Type']).toBe('application/json'); + }); + + test('returns the raw fetch Response so callers can inspect status/ok', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(false); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + const fetchMock = vi.mocked(fetch); + const probe = { ok: false, status: 405, statusText: 'Method Not Allowed' } as Response; + fetchMock.mockResolvedValueOnce(probe); + + const response = await testCoreRpcConnection('http://example.test:7788/rpc'); + + expect(response).toBe(probe); + expect(response.status).toBe(405); + }); + }); }); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index aa05f36dc..70773f743 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -167,6 +167,30 @@ async function getCoreRpcToken(): Promise { return resolvingCoreRpcToken; } +/** + * Probe an arbitrary core RPC URL with `openhuman.ping`. Used by the + * Welcome page's "Test Connection" affordance to validate a user-entered + * RPC URL without going through the cached `getCoreRpcUrl` resolution. + * + * Encapsulates the bearer-token + JSON-RPC envelope assembly that would + * otherwise sit in the calling component, keeping all RPC client behavior + * inside the service per the project guideline ("Keep Tauri IPC and RPC + * client calls localized to services … do not scatter `invoke()` or + * direct RPC calls throughout components"). + */ +export async function testCoreRpcConnection(url: string): Promise { + const token = await getCoreRpcToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'openhuman.ping', params: {} }), + }); +} + export async function getCoreHttpBaseUrl(): Promise { const rpcUrl = await getCoreRpcUrl(); const url = new URL(rpcUrl);