feat(web): route checkConnection and discovery through the proxy
This commit is contained in:
@@ -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<string, string> = { "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" };
|
||||
|
||||
@@ -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<string | null> {
|
||||
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),
|
||||
});
|
||||
|
||||
47
packages/web/src/test/check-connection.test.ts
Normal file
47
packages/web/src/test/check-connection.test.ts
Normal file
@@ -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<string, string>)["X-Honcho-Upstream"]).toBe(
|
||||
"https://honcho.example.net",
|
||||
);
|
||||
expect((init.headers as Record<string, string>).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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user