Files
clawhub/server/convexProxy.test.ts
T
Patrick Erichsen dfca6f5d9d feat: add disposable Vercel and Convex PR previews (#3017)
Adds isolated Convex-backed Vercel PR previews with shared local/preview seeding, preview-safe routing, and production guards.
2026-07-08 20:54:09 -07:00

98 lines
3.4 KiB
TypeScript

/* @vitest-environment node */
import { mockEvent } from "h3";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildConvexProxyTarget,
isConvexProxyMethodAllowed,
proxyConvexRequest,
resolveConvexProxyEnv,
} from "./convexProxy";
describe("Convex HTTP proxy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("maps API and hosted feed paths to the paired Convex site", () => {
const env = {
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
};
expect(buildConvexProxyTarget("/api/v1/skills/demo?include=latest", env)).toBe(
"https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest",
);
expect(buildConvexProxyTarget("/v1/feeds/plugins", env)).toBe(
"https://preview-branch-123.convex.site/api/v1/feeds/plugins",
);
});
it("allows only read methods when the frontend is a preview", () => {
const previewEnv = { VERCEL_ENV: "preview" };
expect(isConvexProxyMethodAllowed("GET", previewEnv)).toBe(true);
expect(isConvexProxyMethodAllowed("HEAD", previewEnv)).toBe(true);
expect(isConvexProxyMethodAllowed("POST", previewEnv)).toBe(false);
expect(isConvexProxyMethodAllowed("DELETE", previewEnv)).toBe(false);
expect(isConvexProxyMethodAllowed("POST", { VERCEL_ENV: "production" })).toBe(true);
});
it("prefers the build-paired Convex URL over stale Vercel runtime values", () => {
expect(
resolveConvexProxyEnv(
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site",
VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud",
},
{
VITE_CLAWHUB_DEPLOY_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site",
VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
},
),
).toEqual({
VERCEL_ENV: "preview",
VITE_CLAWHUB_DEPLOY_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site",
VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
});
});
it("proxies reads and exposes the non-secret preview deployment name for proof", async () => {
const fetchMock = vi.fn(async () => {
return new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
const event = mockEvent("https://preview.example/api/v1/skills/demo?include=latest");
const response = await proxyConvexRequest(event, {
VERCEL_ENV: "preview",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest",
expect.objectContaining({ method: "GET" }),
);
expect(response.headers.get("X-ClawHub-Preview-Backend")).toBe("preview-branch-123");
});
it("rejects preview writes without contacting Convex", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const event = mockEvent("https://preview.example/api/v1/skills/demo", {
method: "POST",
});
const response = await proxyConvexRequest(event, {
VERCEL_ENV: "preview",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
});
expect(response.status).toBe(405);
expect(fetchMock).not.toHaveBeenCalled();
});
});