| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | import worker from "../src/index"; |
| 4 | import { POSTHOG_TIMEOUT_MS } from "../src/posthog"; |
| 5 | import { |
| 6 | CWC_PRODUCT_SCHEMA, ENVELOPE_FIELDS, OPERATIONS_FIELDS, |
| 7 | PRODUCT_COUNTER_FIELDS, validateBatch, |
| 8 | } from "../src/schema"; |
| 9 | import { goldenBatch, harness, postJson } from "./support"; |
| 10 | |
| 11 | const browserV2 = () => JSON.parse(readFileSync(new URL("./golden/browser-v2.json", import.meta.url), "utf8")); |
| 12 | const browser = () => JSON.parse(readFileSync(new URL("./golden/browser-v3.json", import.meta.url), "utf8")); |
| 13 | const current = () => JSON.parse(readFileSync(new URL("../../crates/telemetry/tests/golden/v2.json", import.meta.url), "utf8")); |
| 14 | const configured = () => ({ |
| 15 | ...harness().env, |
| 16 | POSTHOG_HOST: "https://us.i.posthog.com", |
| 17 | POSTHOG_PROJECT_TOKEN: "phc_local_test_fixture", |
| 18 | POSTHOG_IP_SAFE_EGRESS_VERIFIED: "true", |
| 19 | }); |
| 20 | |
| 21 | afterEach(() => vi.unstubAllGlobals()); |
| 22 | |
| 23 | describe("versioned processor policy", () => { |
| 24 | it("accepts the original v1 byte shape first-party only even with an active sink", async () => { |
| 25 | const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); |
| 26 | const { env, written } = harness(); |
| 27 | expect((await worker.fetch(postJson(goldenBatch()), { ...env, ...configured(), TELEMETRY: env.TELEMETRY })).status).toBe(204); |
| 28 | expect(written).toHaveLength(4); |
| 29 | expect(fetch).not.toHaveBeenCalled(); |
| 30 | expect(written.every((point) => point.blobs[19] === "")).toBe(true); |
| 31 | }); |
| 32 | |
| 33 | it.each([undefined, 0, 3, 5, "4", true])("rejects missing or non-current consent %s before any sink", async (consent) => { |
| 34 | const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); |
| 35 | const batch = browserV2(); batch.consent_version = consent; |
| 36 | const { env, written } = harness(); |
| 37 | expect((await worker.fetch(postJson(batch), { ...configured(), ...env })).status).toBe(400); |
| 38 | expect(written).toHaveLength(0); |
| 39 | expect(fetch).not.toHaveBeenCalled(); |
| 40 | }); |
| 41 | |
| 42 | it.each([undefined, 0, 4, 6, "5", true])("rejects missing or invalid v3 notice %s before any sink", async (notice) => { |
| 43 | const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); |
| 44 | const batch = browser(); batch.notice_version = notice; |
| 45 | const { env, written } = harness(); |
| 46 | expect((await worker.fetch(postJson(batch), { ...configured(), ...env })).status).toBe(400); |
| 47 | expect(written).toHaveLength(0); |
| 48 | expect(fetch).not.toHaveBeenCalled(); |
| 49 | }); |
| 50 | |
| 51 | it("keeps explicit v2 consent distinct from default-on v3 notice metadata", async () => { |
| 52 | const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); |
| 53 | vi.stubGlobal("fetch", fetch); |
| 54 | const { env, written } = harness(); |
| 55 | for (const batch of [browserV2(), browser()]) { |
| 56 | expect((await worker.fetch(postJson(batch), { ...configured(), ...env })).status).toBe(204); |
| 57 | } |
| 58 | expect(written.map((point) => point.blobs.slice(18))).toEqual([["2", "4"], ["3", "5"]]); |
| 59 | const properties = fetch.mock.calls.map(([, init]) => JSON.parse(init.body).batch[0].properties); |
| 60 | expect(properties[0]).toMatchObject({ schema_version: 2, consent_version: 4 }); |
| 61 | expect(properties[0]).not.toHaveProperty("notice_version"); |
| 62 | expect(properties[1]).toMatchObject({ schema_version: 3, notice_version: 5 }); |
| 63 | expect(properties[1]).not.toHaveProperty("consent_version"); |
| 64 | |
| 65 | fetch.mockClear(); |
| 66 | for (const batch of [{ ...browserV2(), notice_version: 5 }, { ...browser(), consent_version: 4 }]) { |
| 67 | expect((await worker.fetch(postJson(batch), { ...configured(), ...env })).status).toBe(400); |
| 68 | } |
| 69 | expect(written).toHaveLength(2); |
| 70 | expect(fetch).not.toHaveBeenCalled(); |
| 71 | }); |
| 72 | |
| 73 | it("does not retrofit legacy batches with current consent or new events", async () => { |
| 74 | for (const batch of [ |
| 75 | { ...goldenBatch(), consent_version: 4 }, |
| 76 | { ...goldenBatch(), notice_version: 5 }, |
| 77 | { ...goldenBatch(), events: browser().events }, |
| 78 | { ...goldenBatch(), events: current().events.slice(-1) }, |
| 79 | ]) { |
| 80 | expect((await worker.fetch(postJson(batch), configured())).status).toBe(400); |
| 81 | } |
| 82 | }); |
| 83 | }); |
| 84 | |
| 85 | describe("closed aggregate schema", () => { |
| 86 | it("accepts the runtime v2 fixture and both browser product versions", () => { |
| 87 | expect(validateBatch(current()).ok).toBe(true); |
| 88 | expect(validateBatch(browserV2()).ok).toBe(true); |
| 89 | expect(validateBatch(browser()).ok).toBe(true); |
| 90 | }); |
| 91 | |
| 92 | it.each(PRODUCT_COUNTER_FIELDS)("requires bounded product count %s", (field) => { |
| 93 | for (const invalid of [undefined, -1, 0.5, 4294967296, "1", "private work"]) { |
| 94 | const batch = browser(); batch.events[0].counters[field] = invalid; |
| 95 | expect(validateBatch(batch).ok).toBe(false); |
| 96 | } |
| 97 | const maximum = browser(); maximum.events[0].counters[field] = 4294967295; |
| 98 | expect(validateBatch(maximum).ok).toBe(true); |
| 99 | }); |
| 100 | |
| 101 | it("rejects unknown fields, events, and prototype names", () => { |
| 102 | const mutations = [ |
| 103 | (batch: any) => { batch.url = "https://private.invalid"; }, |
| 104 | (batch: any) => { batch.events[0].prompt = "private work"; }, |
| 105 | (batch: any) => { batch.events[0].counters.account_id = "private"; }, |
| 106 | (batch: any) => { batch.events[0] = { event: "toString" }; }, |
| 107 | (batch: any) => { batch.events[0] = { event: "$identify" }; }, |
| 108 | ]; |
| 109 | for (const mutate of mutations) { |
| 110 | const batch = browser(); mutate(batch); |
| 111 | expect(validateBatch(batch).ok).toBe(false); |
| 112 | } |
| 113 | }); |
| 114 | |
| 115 | it("keeps service health on the control-plane with only aggregate u32 values", () => { |
| 116 | const batch = current(); batch.events = batch.events.slice(-1); |
| 117 | expect(validateBatch(batch).ok).toBe(true); |
| 118 | expect(validateBatch({ ...batch, surface: "web-app" }).ok).toBe(false); |
| 119 | for (const field of OPERATIONS_FIELDS) { |
| 120 | const invalid = structuredClone(batch); invalid.events[0][field] = -1; |
| 121 | expect(validateBatch(invalid).ok).toBe(false); |
| 122 | } |
| 123 | batch.events[0].requestDigest = "private"; |
| 124 | expect(validateBatch(batch).ok).toBe(false); |
| 125 | }); |
| 126 | |
| 127 | it("keeps the cross-repo JSON Schema derived from this authority", () => { |
| 128 | const artifact = JSON.parse(readFileSync(new URL("../schema/cwc-product-v3.schema.json", import.meta.url), "utf8")); |
| 129 | expect(artifact).toEqual(CWC_PRODUCT_SCHEMA); |
| 130 | expect(Object.keys(artifact.properties).sort()).toEqual([...ENVELOPE_FIELDS].sort()); |
| 131 | expect(artifact.properties.surface.enum).toEqual(["web-app", "desktop"]); |
| 132 | expect(Object.keys(artifact.properties.events.items.properties.counters.properties)).toEqual(PRODUCT_COUNTER_FIELDS); |
| 133 | expect(artifact.additionalProperties).toBe(false); |
| 134 | expect(artifact.properties.events.items.additionalProperties).toBe(false); |
| 135 | expect(artifact.properties.events.items.properties.counters.additionalProperties).toBe(false); |
| 136 | }); |
| 137 | }); |
| 138 | |
| 139 | describe("bounded optional PostHog delivery", () => { |
| 140 | it.each([undefined, "", "false", "TRUE", "1"])("requires the exact operator egress prerequisite %s", async (verified) => { |
| 141 | const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); |
| 142 | expect((await worker.fetch(postJson(browser()), { |
| 143 | ...configured(), POSTHOG_IP_SAFE_EGRESS_VERIFIED: verified, |
| 144 | })).status).toBe(204); |
| 145 | expect(fetch).not.toHaveBeenCalled(); |
| 146 | }); |
| 147 | |
| 148 | it.each([ |
| 149 | undefined, "", "http://us.i.posthog.com", "https://posthog.com", |
| 150 | "https://us.i.posthog.com/", "https://us.i.posthog.com?key=bad", |
| 151 | "https://us.i.posthog.com#fragment", "https://us.i.posthog.com:443", |
| 152 | "https://us.i.posthog.com.attacker.invalid", "https://us.i.posthog.com@attacker.invalid", |
| 153 | "https://user:password@us.i.posthog.com", "http://127.0.0.1", |
| 154 | ])("is inert with an absent/untrusted host %s", async (host) => { |
| 155 | const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); |
| 156 | expect((await worker.fetch(postJson(browser()), { ...configured(), POSTHOG_HOST: host })).status).toBe(204); |
| 157 | expect(fetch).not.toHaveBeenCalled(); |
| 158 | }); |
| 159 | |
| 160 | it.each([undefined, "", "not_a_project_token", "phc_bad\nvalue"])("is inert with an absent/invalid project token %s", async (token) => { |
| 161 | const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); |
| 162 | expect((await worker.fetch(postJson(browser()), { ...configured(), POSTHOG_PROJECT_TOKEN: token })).status).toBe(204); |
| 163 | expect(fetch).not.toHaveBeenCalled(); |
| 164 | }); |
| 165 | |
| 166 | it.each(["https://us.i.posthog.com", "https://eu.i.posthog.com"])("sends anonymous aggregate properties to %s without request metadata", async (host) => { |
| 167 | const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); |
| 168 | vi.stubGlobal("fetch", fetch); |
| 169 | const request = postJson(browser()); |
| 170 | request.headers.set("cookie", "private-cookie"); |
| 171 | request.headers.set("authorization", "private-credential"); |
| 172 | request.headers.set("user-agent", "private-agent"); |
| 173 | const { env, written } = harness(); |
| 174 | expect((await worker.fetch(request, { ...configured(), ...env, POSTHOG_HOST: host })).status).toBe(204); |
| 175 | expect(written[0].blobs[17]).toBe(JSON.stringify(browser().events[0].counters)); |
| 176 | expect(written[0].doubles.every((count) => count === 0)).toBe(true); |
| 177 | expect(fetch).toHaveBeenCalledTimes(1); |
| 178 | const [url, init] = fetch.mock.calls[0]; |
| 179 | expect(url).toBe(`${host}/batch/`); |
| 180 | expect(init).toMatchObject({ method: "POST", headers: { "content-type": "application/json" }, redirect: "error", credentials: "omit" }); |
| 181 | expect(init.signal).toBeInstanceOf(AbortSignal); |
| 182 | expect(init.body).not.toContain("private-"); |
| 183 | const captured = JSON.parse(init.body).batch; |
| 184 | expect(captured).toHaveLength(1); |
| 185 | expect(captured[0]).toMatchObject({ |
| 186 | event: "codewhale_product_usage", timestamp: browser().sent_at, |
| 187 | properties: { |
| 188 | schema_version: 3, notice_version: 5, surface: "website", |
| 189 | distinct_id: `codewhale:${browser().install_id}`, |
| 190 | $process_person_profile: false, $geoip_disable: true, $ip: null, |
| 191 | counters: browser().events[0].counters, |
| 192 | }, |
| 193 | }); |
| 194 | expect(Object.keys(JSON.parse(init.body)).sort()).toEqual(["api_key", "batch"]); |
| 195 | expect(captured[0].properties).not.toHaveProperty("install_id"); |
| 196 | expect(captured[0].properties).not.toHaveProperty("consent_version"); |
| 197 | }); |
| 198 | |
| 199 | it.each(["throws", 302, 429, 500])("isolates processor failure %s from first-party success without retry", async (failure) => { |
| 200 | const fetch = failure === "throws" ? vi.fn().mockRejectedValue(new Error("private failure")) |
| 201 | : vi.fn().mockResolvedValue(new Response(null, { status: failure as number })); |
| 202 | vi.stubGlobal("fetch", fetch); |
| 203 | const { env, written } = harness(); |
| 204 | expect((await worker.fetch(postJson(browser()), { ...configured(), ...env })).status).toBe(204); |
| 205 | expect(written).toHaveLength(1); |
| 206 | expect(fetch).toHaveBeenCalledTimes(1); |
| 207 | }); |
| 208 | |
| 209 | it("aborts a stalled processor while preserving the first-party response", async () => { |
| 210 | const fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => { |
| 211 | init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }); |
| 212 | })); |
| 213 | vi.stubGlobal("fetch", fetch); |
| 214 | const start = Date.now(); |
| 215 | expect((await worker.fetch(postJson(browser()), configured())).status).toBe(204); |
| 216 | expect(Date.now() - start).toBeGreaterThanOrEqual(POSTHOG_TIMEOUT_MS - 20); |
| 217 | expect(Date.now() - start).toBeLessThan(POSTHOG_TIMEOUT_MS + 1000); |
| 218 | expect(fetch).toHaveBeenCalledTimes(1); |
| 219 | }); |
| 220 | |
| 221 | it("never combines distinct installations in one processor batch", async () => { |
| 222 | const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); |
| 223 | vi.stubGlobal("fetch", fetch); |
| 224 | const second = browser(); second.install_id = "3f2a9c1e-0000-4000-8000-000000000001"; |
| 225 | await Promise.all([browser(), second].map((batch) => worker.fetch(postJson(batch), configured()))); |
| 226 | expect(fetch).toHaveBeenCalledTimes(2); |
| 227 | expect(fetch.mock.calls.map(([, init]) => JSON.parse(init.body).batch.map((event: any) => event.properties.distinct_id))).toEqual([ |
| 228 | [`codewhale:${browser().install_id}`], [`codewhale:${second.install_id}`], |
| 229 | ]); |
| 230 | }); |
| 231 | }); |
| 232 |