import { afterEach, describe, expect, mock, test } from "bun:test"; const fakeEnv: Record = {}; mock.module("cloudflare:workers", () => ({ env: fakeEnv, waitUntil: () => undefined })); mock.module("./db", () => ({ first: async () => ({ kind: "org" }) })); const { analyticsConfig, captureServerEvent } = await import("./analytics"); const { handleIngestProxy, ingestPath, ingestTarget, ingestUpstream } = await import("./analytics-proxy"); const { scrubErrorMessage } = await import("~/lib/scrub-error"); const PROD = { POSTHOG_KEY: "phc_abc123", PUBLIC_URL: "https://gitfor.dev" }; afterEach(() => { for (const key of Object.keys(fakeEnv)) delete fakeEnv[key]; }); describe("analyticsConfig environments", () => { test("on in production", () => { Object.assign(fakeEnv, PROD); expect(analyticsConfig()).toEqual({ key: "phc_abc123", host: "https://us.i.posthog.com" }); }); test("off in local dev, on loopback and in named non-production environments", () => { Object.assign(fakeEnv, PROD, { GITFOR_LOCAL: "true" }); expect(analyticsConfig()).toBeNull(); Object.assign(fakeEnv, PROD, { GITFOR_LOCAL: undefined, PUBLIC_URL: "http://localhost:3000" }); expect(analyticsConfig()).toBeNull(); Object.assign(fakeEnv, PROD, { GITFOR_ENV: "staging" }); expect(analyticsConfig()).toBeNull(); fakeEnv.GITFOR_ENV = "production"; expect(analyticsConfig()).not.toBeNull(); }); }); describe("captureServerEvent", () => { test("org-owned events carry $groups", async () => { Object.assign(fakeEnv, PROD); const bodies: string[] = []; const original = globalThis.fetch; globalThis.fetch = (async (_url: string, init: RequestInit) => { bodies.push(String(init.body)); return new Response("ok"); }) as typeof fetch; try { captureServerEvent("pull_opened", "user1", { private_repo: true }, { ownerAccountId: "org1" }); await new Promise((resolve) => setTimeout(resolve, 10)); } finally { globalThis.fetch = original; } expect(JSON.parse(bodies[0] ?? "{}").properties.$groups).toEqual({ org: "org1" }); }); }); describe("ingest proxy", () => { test("404 when analytics is off, ignores other paths", async () => { expect(await handleIngestProxy(new Request("https://gitfor.dev/x"), new URL("https://gitfor.dev/x"))).toBeNull(); const off = await handleIngestProxy( new Request("https://gitfor.dev/ingest/e/"), new URL("https://gitfor.dev/ingest/e/"), ); expect(off?.status).toBe(404); }); test("forwards without cookies and drops upstream cookies", async () => { Object.assign(fakeEnv, PROD); let seen: Request | null = null; const original = globalThis.fetch; globalThis.fetch = (async (url: URL, init: RequestInit) => { seen = new Request(url, init); return new Response("ok", { headers: { "set-cookie": "a=b" } }); }) as typeof fetch; try { const url = "https://gitfor.dev/ingest/i/v0/e/?ver=1"; const request = new Request(url, { method: "POST", body: "{}", headers: { cookie: "session=s", "content-type": "application/json", origin: "https://gitfor.dev", referer: "https://gitfor.dev/alice/private-repo/issues/1", }, }); const response = await handleIngestProxy(request, new URL(url)); expect(response?.headers.has("set-cookie")).toBe(false); } finally { globalThis.fetch = original; } const forwarded = seen as Request | null; expect(forwarded?.url).toBe("https://us.i.posthog.com/i/v0/e/?ver=1"); expect(forwarded?.headers.has("cookie")).toBe(false); expect(forwarded?.headers.get("origin")).toBe("https://gitfor.dev"); expect(forwarded?.headers.get("referer")).toBe("https://gitfor.dev/"); }); test("static assets come from the assets host", () => { expect(ingestUpstream("https://eu.i.posthog.com", "/static/recorder.js")).toBe("https://eu-assets.i.posthog.com"); expect(ingestUpstream("https://eu.i.posthog.com", "/e/")).toBe("https://eu.i.posthog.com"); }); }); describe("scrubErrorMessage", () => { test("removes emails, urls, tokens, repo names and quoted values", () => { const out = scrubErrorMessage("bob@x.com https://a.b/c ghp_abc acme/secret 'branch' /a/b"); for (const leaked of ["bob", "a.b", "ghp_", "acme", "secret", "branch", "/a/b"]) expect(out).not.toContain(leaked); }); }); describe("ingest proxy SSRF", () => { const evil = [ "/ingest//evil.example/x", "/ingest///evil.example/x", "/ingest/\\evil.example/x", "/ingest/%2fevil.example/x", "/ingest/%2Fevil.example/x", "/ingest/%5cevil.example/x", "/ingest/%5Cevil.example/x", "/ingest/..", "/ingest/../admin", "/ingest/e/../../x", "/ingest/%2e%2e/x", "/ingest/%2E%2e/x", "/ingest/./e", ]; test("unsafe paths are refused before any target is built", () => { for (const path of evil) expect({ path, ok: ingestPath(path) }).toEqual({ path, ok: null }); expect(ingestPath("/ingest/i/v0/e/")).toBe("/i/v0/e/"); expect(ingestPath("/ingest")).toBe("/"); }); test("targets keep the fixed upstream origin", () => { expect(ingestTarget("https://us.i.posthog.com", "/e/", "?v=1")?.href).toBe("https://us.i.posthog.com/e/?v=1"); expect(ingestTarget("https://us.i.posthog.com", "/static/a.js", "")?.origin).toBe( "https://us-assets.i.posthog.com", ); expect(ingestTarget("https://us.i.posthog.com", "//evil.example/x", "")?.origin ?? null).not.toBe( "https://evil.example", ); }); test("the handler never fetches an off-origin host", async () => { Object.assign(fakeEnv, PROD); const fetched: string[] = []; const original = globalThis.fetch; globalThis.fetch = (async (url: URL) => { fetched.push(String(url)); return new Response("ok"); }) as typeof fetch; try { for (const path of evil) { const raw = `https://gitfor.dev${path}`; const response = await handleIngestProxy(new Request(raw), new URL(raw)); // Dot segments are collapsed by URL parsing before the handler sees them, landing on a plain upstream path. if (response && !/(^|\/)\.\.?(\/|$)/.test(path)) { expect({ path, status: response.status }).toEqual({ path, status: 400 }); } } } finally { globalThis.fetch = original; } for (const url of fetched) expect(new URL(url).origin).toMatch(/^https:\/\/us(-assets)?\.i\.posthog\.com$/); }); });