返回 DeepSeek-Reasonix
firebase_rtdb.test.ts
根目录 / workers / crash-report / src / firebase_rtdb.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2 import type { Env } from "./env";
3 import {
4 FirebaseFenceError,
5 deleteFirebaseCrashGroup,
6 readFirebaseCrashGroup,
7 resetFirebaseAuthForTests,
8 writeFirebaseCrashGroup,
9 writeFirebaseGroupMeta,
10 type FirebaseCrashGroupMeta,
11 } from "./firebase_rtdb";
12
13 const oauthURL = "https://oauth2.googleapis.com/token";
14 const databaseHost = "reasonix-test.asia-southeast1.firebasedatabase.app";
15
16 async function privateKeyPEM(): Promise<string> {
17 const pair = await crypto.subtle.generateKey(
18 { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
19 true,
20 ["sign", "verify"],
21 ) as CryptoKeyPair;
22 const bytes = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey) as ArrayBuffer);
23 let binary = "";
24 for (const byte of bytes) binary += String.fromCharCode(byte);
25 const encoded = btoa(binary).match(/.{1,64}/g)?.join("\n") ?? "";
26 return `-----BEGIN PRIVATE KEY-----\n${encoded}\n-----END PRIVATE KEY-----`;
27 }
28
29 const meta: FirebaseCrashGroupMeta = {
30 fingerprint: "a".repeat(64), kind: "crash", count: 1,
31 firstSeen: "2026-08-25T00:00:00.000Z", lastSeen: "2026-08-25T00:00:00.000Z",
32 firstVersion: "v1.0.0", lastVersion: "v1.0.0", status: "open", title: "boom",
33 source: "go", label: "panic", errorType: "error", topFrame: "main.go:<n>", severity: "high",
34 lastOS: "linux", lastArch: "amd64", lastBuildCommit: "", lastChannel: "stable", regressedAt: "",
35 };
36
37 async function firebaseEnv(): Promise<Env> {
38 return {
39 FIREBASE_DATABASE_URL: `https://${databaseHost}`,
40 FIREBASE_CLIENT_EMAIL: "crash-writer@example.iam.gserviceaccount.com",
41 FIREBASE_PRIVATE_KEY: await privateKeyPEM(),
42 } as Env;
43 }
44
45 type Call = { url: string; init?: RequestInit };
46
47 function storeFetcher(initial: Record<string, unknown> = {}) {
48 const values = new Map(Object.entries(initial));
49 const versions = new Map<string, number>();
50 const calls: Call[] = [];
51 const pathOf = (url: string) => new URL(url).pathname.replace(/^\//, "").replace(/\.json$/, "");
52 const fetcher: typeof fetch = async (input, init) => {
53 const url = String(input);
54 calls.push({ url, init });
55 if (url === oauthURL) return Response.json({ access_token: "token", expires_in: 3600 });
56 const path = pathOf(url);
57 const method = init?.method ?? "GET";
58 const version = versions.get(path) ?? 1;
59 if (method === "GET") {
60 return Response.json(values.get(path) ?? null, { headers: { etag: `"${version}"` } });
61 }
62 const headers = new Headers(init?.headers);
63 if (headers.has("If-Match") && headers.get("If-Match") !== `"${version}"`) {
64 return Response.json(values.get(path) ?? null, { status: 412, headers: { etag: `"${version}"` } });
65 }
66 if (method === "DELETE") values.delete(path);
67 else values.set(path, JSON.parse(String(init?.body)) as unknown);
68 versions.set(path, version + 1);
69 return new Response(null, { status: 204 });
70 };
71 return { fetcher, calls, values, versions };
72 }
73
74 describe("Firebase Realtime Database conditional delivery", () => {
75 beforeEach(() => resetFirebaseAuthForTests());
76 afterEach(() => { vi.useRealTimers(); resetFirebaseAuthForTests(); });
77
78 it("coalesces OAuth and writes only meta, first, and one ring slot with ETags", async () => {
79 const env = await firebaseEnv();
80 const store = storeFetcher();
81 const sample = { eventId: "b".repeat(32), receivedAt: meta.firstSeen, message: "sanitized" };
82 await Promise.all([
83 writeFirebaseCrashGroup(env, meta, sample, 0, true, 1, 1, undefined, store.fetcher),
84 writeFirebaseCrashGroup(env, { ...meta, count: 2 }, sample, 1, false, 2, 1, undefined, store.fetcher),
85 ]);
86 expect(store.calls.filter((call) => call.url === oauthURL)).toHaveLength(1);
87 const databaseCalls = store.calls.filter((call) => new URL(call.url).hostname === databaseHost);
88 expect(databaseCalls.every((call) => !call.url.includes(`/groups/${meta.fingerprint}.json`))).toBe(true);
89 const puts = databaseCalls.filter((call) => call.init?.method === "PUT");
90 expect(puts.length).toBeGreaterThanOrEqual(4);
91 expect(puts.every((call) => new Headers(call.init?.headers).has("If-Match"))).toBe(true);
92 expect(puts.every((call) => call.url.endsWith("?print=silent"))).toBe(true);
93 expect(JSON.stringify([...store.values.values()])).not.toContain("installId");
94 });
95
96 it("retries a 412 at most through a fresh ETag and fences a stale generation", async () => {
97 const env = await firebaseEnv();
98 const store = storeFetcher();
99 let injected = false;
100 const fetcher: typeof fetch = async (input, init) => {
101 if (String(input) !== oauthURL && init?.method === "PUT" && !injected) {
102 injected = true;
103 return Response.json({ writerGeneration: 0 }, { status: 412, headers: { etag: '"2"' } });
104 }
105 return store.fetcher(input, init);
106 };
107 await writeFirebaseCrashGroup(
108 env, meta, { eventId: "c".repeat(32), receivedAt: meta.firstSeen }, 0, true, 2, 1, undefined, fetcher,
109 );
110 expect(injected).toBe(true);
111
112 resetFirebaseAuthForTests();
113 const metaPath = `groups/${meta.fingerprint}/meta`;
114 const fenced = storeFetcher({ [metaPath]: { ...meta, writerGeneration: 11, sampleEpoch: 1 } });
115 await expect(writeFirebaseCrashGroup(
116 env, meta, { eventId: "d".repeat(32), receivedAt: meta.firstSeen }, 0, false, 10, 1, undefined,
117 fenced.fetcher,
118 )).rejects.toBeInstanceOf(FirebaseFenceError);
119 expect(fenced.calls.filter((call) => call.init?.method === "PUT")).toHaveLength(0);
120 });
121
122 it("refreshes once after 401 and never includes credential bodies in errors", async () => {
123 const env = await firebaseEnv();
124 let tokenRequests = 0;
125 const store = storeFetcher();
126 let rejected = false;
127 const fetcher: typeof fetch = async (input, init) => {
128 if (String(input) === oauthURL) {
129 tokenRequests++;
130 return Response.json({ access_token: `token-${tokenRequests}`, expires_in: 3600 });
131 }
132 if (!rejected) { rejected = true; return new Response("expired", { status: 401 }); }
133 return store.fetcher(input, init);
134 };
135 await writeFirebaseCrashGroup(
136 env, meta, { eventId: "e".repeat(32), receivedAt: meta.firstSeen }, 0, true, 1, 1, undefined, fetcher,
137 );
138 expect(tokenRequests).toBe(2);
139
140 resetFirebaseAuthForTests();
141 const secret = "private-key-material-must-not-leak";
142 const failing: typeof fetch = async () => new Response(secret, { status: 403 });
143 await expect(writeFirebaseCrashGroup(
144 env, meta, { eventId: "f".repeat(32), receivedAt: meta.firstSeen }, 0, true, 1, 1, undefined, failing,
145 )).rejects.not.toThrow(secret);
146 });
147
148 it("bounds OAuth and rejects non-Firebase hosts before sample transmission", async () => {
149 vi.useFakeTimers();
150 const env = await firebaseEnv();
151 let started!: () => void;
152 const requestStarted = new Promise<void>((resolve) => { started = resolve; });
153 const hanging: typeof fetch = async (_input, init) => {
154 started();
155 return new Promise<Response>((_resolve, reject) => {
156 init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
157 });
158 };
159 const pending = writeFirebaseCrashGroup(
160 env, meta, { eventId: "1".repeat(32), receivedAt: meta.firstSeen }, 0, true, 1, 1, undefined, hanging,
161 );
162 const rejection = expect(pending).rejects.toThrow("aborted");
163 await requestStarted;
164 await vi.advanceTimersByTimeAsync(5_001);
165 await rejection;
166 vi.useRealTimers();
167
168 resetFirebaseAuthForTests();
169 const invalid = { ...await firebaseEnv(), FIREBASE_DATABASE_URL: "https://example.com" } as Env;
170 let databaseCalls = 0;
171 const fetcher: typeof fetch = async (input) => {
172 if (String(input) === oauthURL) return Response.json({ access_token: "token", expires_in: 3600 });
173 databaseCalls++;
174 return Response.json(null);
175 };
176 await expect(writeFirebaseCrashGroup(
177 invalid, meta, { eventId: "2".repeat(32), receivedAt: meta.firstSeen }, 0, true, 1, 1, undefined, fetcher,
178 )).rejects.toThrow("approved Realtime Database host");
179 expect(databaseCalls).toBe(0);
180 });
181
182 it("reads groups and conditionally replaces metadata while legacy delete remains available", async () => {
183 const env = await firebaseEnv();
184 const root = `groups/${meta.fingerprint}`;
185 const sample = { eventId: "3".repeat(32), receivedAt: meta.firstSeen, groupCount: 1, writerGeneration: 1, sampleEpoch: 1 };
186 const store = storeFetcher({ [root]: { meta, samples: { first: sample, latest: { 0: sample } } } });
187 expect((await readFirebaseCrashGroup(env, meta.fingerprint, store.fetcher))?.samples?.first).toEqual(sample);
188 await writeFirebaseGroupMeta(env, meta.fingerprint, { ...meta, severity: "critical" }, 2, 1, "active", undefined, store.fetcher);
189 await deleteFirebaseCrashGroup(env, meta.fingerprint, store.fetcher);
190 expect(store.calls.map((call) => call.init?.method ?? "GET")).toEqual([
191 "POST", "GET", "GET", "PUT", "DELETE",
192 ]);
193 });
194 });
195
195 lines TYPESCRIPT