feat(web): route checkConnection and discovery through the proxy
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";
|
||||||
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
||||||
|
|
||||||
const LEGACY_KEY = "openconcho:config";
|
const LEGACY_KEY = "openconcho:config";
|
||||||
@@ -162,21 +162,20 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
|||||||
export async function checkConnection(
|
export async function checkConnection(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
token?: string,
|
token?: string,
|
||||||
): Promise<{
|
): Promise<{ status: HealthStatus; message: string }> {
|
||||||
status: HealthStatus;
|
|
||||||
message: string;
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
const res = await fetch(`${base}/v3/workspaces/list`, {
|
||||||
|
|
||||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
signal: AbortSignal.timeout(5000),
|
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.ok) return { status: "ok", message: "Connected successfully" };
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
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";
|
import { isTauri } from "@/lib/platform";
|
||||||
|
|
||||||
export { 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> {
|
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
signal: AbortSignal.timeout(2000),
|
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