返回 CodeWhale
product-usage.test.ts
根目录 / web / lib / telemetry / product-usage.test.ts
1 import { existsSync, readFileSync } from "node:fs";
2 import { describe, expect, it } from "vitest";
3 import { handleProductTelemetry, ingestUrl, CANONICAL_INGEST_URL } from "../../app/api/product-telemetry/route";
4 import {
5 COUNTERS_STORAGE_KEY,
6 INSTALL_ID_ROTATION_MS,
7 INSTALL_STORAGE_KEY,
8 NOTICE_VERSION,
9 PREFERENCE_STORAGE_KEY,
10 SCHEMA_VERSION,
11 buildEnvelope,
12 createUsageRecorder,
13 emptyCounters,
14 readUsagePreference,
15 resolveInstallId,
16 usageCountingEnabled,
17 usagePreferenceRecord,
18 validateEnvelope,
19 type StorageLike,
20 } from "./product-usage";
21
22 /** The backend's golden browser fixture, when the ingest checkout carries it. */
23 const GOLDEN_PATH = new URL("../../../telemetry-ingest/test/golden/browser-v3.json", import.meta.url);
24
25 const UUID = "82b77c4f-4cce-4c74-8e17-8a38ba0581ee";
26 const NOW = Date.parse("2026-09-04T20:00:00Z");
27
28 function memoryStorage(seed: Record<string, string> = {}): StorageLike & { data: Map<string, string> } {
29 const data = new Map(Object.entries(seed));
30 return {
31 data,
32 getItem: (key) => data.get(key) ?? null,
33 setItem: (key, value) => void data.set(key, value),
34 removeItem: (key) => void data.delete(key),
35 };
36 }
37
38 function recorderWith(storage: StorageLike, sent: string[] = [], now = () => NOW) {
39 const timers: (() => void)[] = [];
40 const recorder = createUsageRecorder({
41 surface: "website",
42 appVersion: "0.9.12",
43 endpoint: "/api/product-telemetry",
44 storage,
45 now,
46 randomUuid: () => UUID,
47 send: async (_endpoint, body) => {
48 sent.push(body);
49 return true;
50 },
51 setTimer: (callback) => {
52 timers.push(callback);
53 return timers.length;
54 },
55 clearTimer: (handle) => {
56 timers[(handle as number) - 1] = () => {};
57 },
58 });
59 return { recorder, timers, sent };
60 }
61
62 describe("product usage envelope", () => {
63 it("matches the closed browser contract and the backend's golden fixture", () => {
64 const counters = emptyCounters();
65 counters.page_view = 1;
66 const envelope = buildEnvelope({ counters, installId: UUID, appVersion: "0.9.12", surface: "website", now: NOW });
67 expect(validateEnvelope(envelope)).toEqual({ ok: true, envelope });
68 expect(SCHEMA_VERSION).toBe(3);
69 expect(NOTICE_VERSION).toBe(5);
70 expect(envelope.notice_version).toBe(5);
71 expect(envelope).not.toHaveProperty("consent_version");
72 expect(envelope.sent_at).toBe("2026-09-04T20:00:00Z");
73 if (existsSync(GOLDEN_PATH)) {
74 const golden = JSON.parse(readFileSync(GOLDEN_PATH, "utf8"));
75 expect(envelope).toEqual(golden);
76 expect(validateEnvelope(golden).ok).toBe(true);
77 }
78 });
79
80 it("rejects anything outside the closed field set", () => {
81 const base = buildEnvelope({ counters: emptyCounters(), installId: UUID, appVersion: "0.9.12", surface: "website", now: NOW });
82 expect(validateEnvelope({ ...base, referrer: "x" }).ok).toBe(false);
83 expect(validateEnvelope({ ...base, git_sha: "abc" }).ok).toBe(false);
84 expect(validateEnvelope({ ...base, surface: "tui" }).ok).toBe(false);
85 expect(validateEnvelope({ ...base, notice_version: 4 }).ok).toBe(false);
86 expect(validateEnvelope({ ...base, schema_version: 2 }).ok).toBe(false);
87 // The retired consent field never rides along with the policy version.
88 expect(validateEnvelope({ ...base, consent_version: 4 }).ok).toBe(false);
89 expect(validateEnvelope({ ...base, install_id: "not-a-uuid" }).ok).toBe(false);
90 expect(validateEnvelope({ ...base, events: [] }).ok).toBe(false);
91 const extraCounter = structuredClone(base) as unknown as { events: [{ counters: Record<string, number> }] };
92 extraCounter.events[0].counters.url = 1;
93 expect(validateEnvelope(extraCounter).ok).toBe(false);
94 const negative = structuredClone(base) as unknown as { events: [{ counters: Record<string, number> }] };
95 negative.events[0].counters.page_view = -1;
96 expect(validateEnvelope(negative).ok).toBe(false);
97 // The website route only ever accepts the website surface.
98 expect(validateEnvelope({ ...base, surface: "web-app" }, { surfaces: ["website"] }).ok).toBe(false);
99 });
100 });
101
102 describe("usage preference", () => {
103 it("is on by default, keeps every recorded opt-out, and fails closed on unreadable state", () => {
104 expect(readUsagePreference(null)).toBe("default");
105 expect(readUsagePreference(undefined)).toBe("default");
106 expect(readUsagePreference("")).toBe("default");
107 expect(usageCountingEnabled("default")).toBe(true);
108 // Unreadable stored state is never replaced with the default.
109 expect(readUsagePreference("{not json")).toBe("off");
110 expect(readUsagePreference(JSON.stringify({ version: 4 }))).toBe("off");
111 expect(readUsagePreference(JSON.stringify({ version: 4, granted: "yes" }))).toBe("off");
112 // An opt-out recorded under the old opt-in policy is still an opt-out.
113 expect(readUsagePreference(JSON.stringify({ version: 4, granted: false }))).toBe("off");
114 expect(readUsagePreference(JSON.stringify({ version: 3, granted: false }))).toBe("off");
115 expect(readUsagePreference(JSON.stringify({ version: 4, granted: true }))).toBe("on");
116 expect(readUsagePreference(usagePreferenceRecord(false, NOW))).toBe("off");
117 expect(readUsagePreference(usagePreferenceRecord(true, NOW))).toBe("on");
118 expect(JSON.parse(usagePreferenceRecord(false, NOW))).toEqual({ version: NOTICE_VERSION, granted: false, decidedAt: "2026-09-04T20:00:00Z" });
119 });
120
121 it("counts by default without writing any preference record", async () => {
122 const storage = memoryStorage();
123 const { recorder, timers, sent } = recorderWith(storage);
124 expect(recorder.preference()).toBe("default");
125 recorder.record("page_view");
126 recorder.record("page_view");
127 recorder.record("docs_view");
128 expect(timers).toHaveLength(1);
129 timers[0]();
130 await Promise.resolve();
131 expect(sent).toHaveLength(1);
132 const body = JSON.parse(sent[0]);
133 expect(validateEnvelope(body).ok).toBe(true);
134 expect(body.events[0].counters.page_view).toBe(2);
135 expect(body.events[0].counters.docs_view).toBe(1);
136 expect(body.install_id).toBe(UUID);
137 // Discarded after the attempt; nothing was recorded as an acceptance.
138 expect(recorder.pending()).toEqual(emptyCounters());
139 expect(storage.data.has(COUNTERS_STORAGE_KEY)).toBe(false);
140 expect(storage.data.has(PREFERENCE_STORAGE_KEY)).toBe(false);
141 });
142
143 it("counts nothing and stores nothing after an opt-out, including one recorded under the old policy", () => {
144 for (const seed of [
145 { [PREFERENCE_STORAGE_KEY]: JSON.stringify({ version: 4, granted: false }) },
146 { [PREFERENCE_STORAGE_KEY]: usagePreferenceRecord(false, NOW) },
147 { [PREFERENCE_STORAGE_KEY]: "{corrupt" },
148 ]) {
149 const storage = memoryStorage({ ...seed, [COUNTERS_STORAGE_KEY]: JSON.stringify({ page_view: 9 }), [INSTALL_STORAGE_KEY]: "stale" });
150 const { recorder, timers, sent } = recorderWith(storage);
151 expect(recorder.preference()).toBe("off");
152 recorder.record("page_view");
153 recorder.record("install_copy");
154 expect(recorder.pending()).toEqual(emptyCounters());
155 expect(storage.data.has(COUNTERS_STORAGE_KEY)).toBe(false);
156 expect(storage.data.has(INSTALL_STORAGE_KEY)).toBe(false);
157 expect(timers).toHaveLength(0);
158 expect(sent).toHaveLength(0);
159 }
160 });
161
162 it("clears queued counts and identity on opt-out, cancels pending delivery, and re-enables only deliberately", async () => {
163 const storage = memoryStorage();
164 const { recorder, timers, sent } = recorderWith(storage);
165 recorder.record("page_view");
166 await recorder.flush();
167 expect(sent).toHaveLength(1);
168 expect(storage.data.has(INSTALL_STORAGE_KEY)).toBe(true);
169 recorder.record("download");
170 expect(timers).toHaveLength(2);
171 recorder.disable();
172 expect(recorder.preference()).toBe("off");
173 expect(recorder.pending()).toEqual(emptyCounters());
174 expect(storage.data.has(INSTALL_STORAGE_KEY)).toBe(false);
175 expect(storage.data.has(COUNTERS_STORAGE_KEY)).toBe(false);
176 timers[1]();
177 await Promise.resolve();
178 expect(sent).toHaveLength(1);
179 recorder.record("page_view");
180 expect(recorder.pending()).toEqual(emptyCounters());
181 recorder.enable();
182 expect(recorder.preference()).toBe("on");
183 recorder.record("page_view");
184 expect(recorder.pending().page_view).toBe(1);
185 });
186
187 it("honours another tab's opt-out through sync()", () => {
188 const storage = memoryStorage();
189 const { recorder } = recorderWith(storage);
190 recorder.record("page_view");
191 expect(recorder.pending().page_view).toBe(1);
192 // Another tab turns it off: the shared storage changes underneath us.
193 storage.setItem(PREFERENCE_STORAGE_KEY, usagePreferenceRecord(false, NOW));
194 recorder.sync();
195 expect(recorder.pending()).toEqual(emptyCounters());
196 expect(storage.data.has(COUNTERS_STORAGE_KEY)).toBe(false);
197 });
198 });
199
200 describe("install id", () => {
201 it("is a random v4 id, kept for 90 days and then rotated", () => {
202 const fresh = resolveInstallId(null, NOW, () => UUID);
203 expect(fresh).toEqual({ id: UUID, raw: JSON.stringify({ id: UUID, createdAt: NOW }), rotated: true });
204 const kept = resolveInstallId(fresh.raw, NOW + INSTALL_ID_ROTATION_MS - 1, () => "unused");
205 expect(kept.id).toBe(UUID);
206 expect(kept.rotated).toBe(false);
207 const rotated = resolveInstallId(fresh.raw, NOW + INSTALL_ID_ROTATION_MS, () => "9f1a2b3c-4d5e-4f60-8a1b-2c3d4e5f6a7b");
208 expect(rotated.id).toBe("9f1a2b3c-4d5e-4f60-8a1b-2c3d4e5f6a7b");
209 expect(rotated.rotated).toBe(true);
210 expect(resolveInstallId("garbage", NOW, () => UUID).rotated).toBe(true);
211 });
212 });
213
214 describe("same-origin forwarder", () => {
215 const envelope = buildEnvelope({ counters: { ...emptyCounters(), page_view: 1 }, installId: UUID, appVersion: "0.9.12", surface: "website", now: NOW });
216 const post = (body: unknown) =>
217 new Request("http://localhost/api/product-telemetry", {
218 method: "POST",
219 headers: { "content-type": "application/json" },
220 body: typeof body === "string" ? body : JSON.stringify(body),
221 });
222
223 it("is inert without the exact canonical ingest configured", async () => {
224 expect(ingestUrl({})).toBeNull();
225 expect(ingestUrl({ CODEWHALE_TELEMETRY_INGEST_URL: "https://example.com/v1/telemetry" })).toBeNull();
226 expect(ingestUrl({ CODEWHALE_TELEMETRY_INGEST_URL: CANONICAL_INGEST_URL })).toBe(CANONICAL_INGEST_URL);
227 let forwarded = 0;
228 const response = await handleProductTelemetry(post(envelope), {
229 ingestUrl: null,
230 forward: async () => {
231 forwarded += 1;
232 return { ok: true };
233 },
234 });
235 expect(response.status).toBe(200);
236 expect(await response.json()).toEqual({ accepted: false, reason: "disabled" });
237 expect(forwarded).toBe(0);
238 });
239
240 it("forwards only a validated website batch, and only the batch", async () => {
241 const calls: { url: string; body: string }[] = [];
242 const forward = async (url: string, body: string) => {
243 calls.push({ url, body });
244 return { ok: true };
245 };
246 const accepted = await handleProductTelemetry(post(envelope), { ingestUrl: CANONICAL_INGEST_URL, forward });
247 expect(await accepted.json()).toEqual({ accepted: true });
248 expect(calls).toHaveLength(1);
249 expect(calls[0].url).toBe(CANONICAL_INGEST_URL);
250 expect(JSON.parse(calls[0].body)).toEqual(envelope);
251
252 const rejected = await handleProductTelemetry(post({ ...envelope, surface: "web-app" }), { ingestUrl: CANONICAL_INGEST_URL, forward });
253 expect(rejected.status).toBe(422);
254 expect(calls).toHaveLength(1);
255
256 const invalid = await handleProductTelemetry(post("{"), { ingestUrl: CANONICAL_INGEST_URL, forward });
257 expect(invalid.status).toBe(422);
258
259 const oversized = await handleProductTelemetry(post({ ...envelope, app_version: "0.9.12-" + "x".repeat(5000) }), { ingestUrl: CANONICAL_INGEST_URL, forward });
260 expect(oversized.status).toBe(413);
261 expect(calls).toHaveLength(1);
262 });
263
264 it.each([undefined, "1", "4096"])("cancels oversized chunks with declared length %s and never forwards", async (length) => {
265 let cancelled = false;
266 let forwarded = 0;
267 const body = new ReadableStream<Uint8Array>({
268 start(controller) {
269 controller.enqueue(new Uint8Array(2048));
270 controller.enqueue(new Uint8Array(2049));
271 // Deliberately leave the source open: overflow must cancel it.
272 },
273 cancel() { cancelled = true; },
274 });
275 const request = new Request("http://localhost/api/product-telemetry", {
276 method: "POST", body, duplex: "half",
277 headers: length === undefined ? {} : { "content-length": length },
278 } as RequestInit);
279 const response = await handleProductTelemetry(request, {
280 ingestUrl: CANONICAL_INGEST_URL,
281 forward: async () => { forwarded += 1; return { ok: true }; },
282 });
283 expect(response.status).toBe(413);
284 expect(await response.json()).toEqual({ accepted: false, reason: "too_large" });
285 expect(cancelled).toBe(true);
286 expect(request.body?.locked).toBe(false);
287 expect(forwarded).toBe(0);
288 });
289
290 it("bounds UTF-8 bytes rather than JS character count", async () => {
291 let forwarded = 0;
292 const response = await handleProductTelemetry(post("鲸".repeat(1400)), {
293 ingestUrl: CANONICAL_INGEST_URL,
294 forward: async () => { forwarded += 1; return { ok: true }; },
295 });
296 expect(response.status).toBe(413);
297 expect(forwarded).toBe(0);
298 });
299
300 it("accepts an exact 4096-byte valid envelope across chunks", async () => {
301 const json = JSON.stringify(envelope);
302 const bytes = new TextEncoder().encode(json + " ".repeat(4096 - new TextEncoder().encode(json).byteLength));
303 const body = new ReadableStream<Uint8Array>({ start(controller) {
304 for (let offset = 0; offset < bytes.length; offset += 7) controller.enqueue(bytes.slice(offset, offset + 7));
305 controller.close();
306 } });
307 let forwarded = 0;
308 const response = await handleProductTelemetry(new Request("http://localhost/api/product-telemetry", {
309 method: "POST", body, duplex: "half",
310 } as RequestInit), {
311 ingestUrl: CANONICAL_INGEST_URL,
312 forward: async (_url, value) => { forwarded += 1; expect(JSON.parse(value)).toEqual(envelope); return { ok: true }; },
313 });
314 expect(await response.json()).toEqual({ accepted: true });
315 expect(forwarded).toBe(1);
316 });
317
318 it.each(["stream_failure", "invalid_utf8", "invalid_length"])("rejects %s without forwarding", async (kind) => {
319 const body = new ReadableStream<Uint8Array>({ start(controller) {
320 if (kind === "stream_failure") controller.error(new Error("read failed"));
321 else { controller.enqueue(new Uint8Array([0xff])); controller.close(); }
322 } });
323 let forwarded = 0;
324 const response = await handleProductTelemetry(new Request("http://localhost/api/product-telemetry", {
325 method: "POST", body, duplex: "half",
326 headers: kind === "invalid_length" ? { "content-length": "no" } : {},
327 } as RequestInit), {
328 ingestUrl: CANONICAL_INGEST_URL,
329 forward: async () => { forwarded += 1; return { ok: true }; },
330 });
331 expect(response.status).toBe(kind === "invalid_utf8" ? 422 : 400);
332 expect(forwarded).toBe(0);
333 });
334
335 it("reports an unreachable ingest as unavailable without retrying", async () => {
336 let attempts = 0;
337 const response = await handleProductTelemetry(post(envelope), {
338 ingestUrl: CANONICAL_INGEST_URL,
339 forward: async () => {
340 attempts += 1;
341 throw new Error("down");
342 },
343 });
344 expect(await response.json()).toEqual({ accepted: false, reason: "unavailable" });
345 expect(attempts).toBe(1);
346 });
347 });
348
348 lines TYPESCRIPT