返回 CodeWhale
posthog.test.ts
根目录 / telemetry-ingest / test / posthog.test.ts
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
232 lines TYPESCRIPT