| 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 |