diff --git a/packages/web/src/lib/config.ts b/packages/web/src/lib/config.ts index 6dda1dd..5ce26ab 100644 --- a/packages/web/src/lib/config.ts +++ b/packages/web/src/lib/config.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { httpFetch } from "@/lib/http"; +import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch"; import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig"; const LEGACY_KEY = "openconcho:config"; @@ -162,21 +162,20 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking"; export async function checkConnection( baseUrl: string, token?: string, -): Promise<{ - status: HealthStatus; - message: string; -}> { +): Promise<{ status: HealthStatus; message: string }> { try { - const headers: Record = { "Content-Type": "application/json" }; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, { + const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token }); + const res = await fetch(`${base}/v3/workspaces/list`, { method: "POST", headers, body: JSON.stringify({}), signal: AbortSignal.timeout(5000), }); + const reject = res.headers.get(PROXY_REJECT_HEADER); + if (reject) { + return { status: "unreachable", message: `Proxy refused upstream (${reject})` }; + } if (res.ok) return { status: "ok", message: "Connected successfully" }; if (res.status === 401 || res.status === 403) { return { status: "auth-required", message: "Authentication required — provide an API token" }; diff --git a/packages/web/src/lib/discovery.ts b/packages/web/src/lib/discovery.ts index 7d983c4..6784f80 100644 --- a/packages/web/src/lib/discovery.ts +++ b/packages/web/src/lib/discovery.ts @@ -1,4 +1,4 @@ -import { httpFetch } from "@/lib/http"; +import { dispatchFor } from "@/lib/dispatch"; import { isTauri } from "@/lib/platform"; export { isTauri } from "@/lib/platform"; @@ -37,9 +37,10 @@ export function deriveNameFromWorkspaceId(workspaceId: string): string { */ export async function suggestNameForInstance(baseUrl: string): Promise { try { - const res = await httpFetch(`${baseUrl}/v3/workspaces/list?page=1&page_size=1`, { + const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl }); + const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({}), signal: AbortSignal.timeout(2000), }); diff --git a/packages/web/src/test/check-connection.test.ts b/packages/web/src/test/check-connection.test.ts new file mode 100644 index 0000000..1e0b8eb --- /dev/null +++ b/packages/web/src/test/check-connection.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock the platform predicate (web mode) and the fetch boundary. We mock +// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch +// reference at module load, so vi.stubGlobal would not be observed by dispatchFor. +const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({ + mockIsTauri: vi.fn(() => false), + httpFetchMock: vi.fn(), +})); +vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() })); +vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock })); + +import { checkConnection } from "@/lib/config"; + +afterEach(() => { + httpFetchMock.mockReset(); + mockIsTauri.mockReturnValue(false); +}); + +describe("checkConnection — web proxy mode", () => { + it("calls the absolute same-origin /api path with the upstream header", async () => { + httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const res = await checkConnection("https://honcho.example.net", "sk-1"); + expect(res.status).toBe("ok"); + const [url, init] = httpFetchMock.mock.calls[0]; + expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`); + expect((init.headers as Record)["X-Honcho-Upstream"]).toBe( + "https://honcho.example.net", + ); + expect((init.headers as Record).Authorization).toBe("Bearer sk-1"); + }); + + it("maps an upstream 401 to auth-required", async () => { + httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 })); + const res = await checkConnection("https://honcho.example.net"); + expect(res.status).toBe("auth-required"); + }); + + it("treats a proxy reject as unreachable, not auth-required", async () => { + httpFetchMock.mockResolvedValue( + new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }), + ); + const res = await checkConnection("https://blocked.example.net"); + expect(res.status).toBe("unreachable"); + expect(res.message).toMatch(/allowlist/i); + }); +});