返回 DeepSeek-Reasonix
firebase_lifecycle.test.ts
根目录 / workers / crash-report / src / firebase_lifecycle.test.ts
1 import { afterEach, describe, expect, it, vi } from "vitest";
2 // @ts-expect-error Node 22+ provides node:sqlite; Worker production code does not import it.
3 import { DatabaseSync } from "node:sqlite";
4 import freshSchemaSQL from "../schema.sql?raw";
5 import type { Env } from "./env";
6 import {
7 FIREBASE_ACTIVE_RESERVATION_BYTES,
8 FIREBASE_ARCHIVING_RESERVATION_BYTES,
9 FIREBASE_COMPACTED_RESERVATION_BYTES,
10 reserveFirebaseGroup,
11 } from "./crash_delivery";
12 import { runFirebaseCrashLifecycle } from "./firebase_lifecycle";
13 import { resetFirebaseAuthForTests } from "./firebase_rtdb";
14
15 type SQLiteStatement = D1PreparedStatement & { execute(): D1Result };
16
17 function sqliteD1(db: DatabaseSync): D1Database {
18 return {
19 prepare(sql: string) {
20 let binds: unknown[] = [];
21 const statement = {
22 bind(...values: unknown[]) { binds = values; return statement; },
23 async first<T>() { return (db.prepare(sql).get(...binds) ?? null) as T | null; },
24 async all<T>() { return { success: true, results: db.prepare(sql).all(...binds) as T[], meta: {} }; },
25 async run() { return statement.execute(); },
26 execute() {
27 const result = db.prepare(sql).run(...binds);
28 return { success: true, results: [], meta: { changes: Number(result.changes) } } as unknown as D1Result;
29 },
30 raw() { return Promise.resolve([]); },
31 } as unknown as SQLiteStatement;
32 return statement;
33 },
34 async batch(statements: D1PreparedStatement[]) {
35 db.exec("BEGIN IMMEDIATE");
36 try {
37 const results = statements.map((statement) => (statement as SQLiteStatement).execute());
38 db.exec("COMMIT");
39 return results;
40 } catch (error) { db.exec("ROLLBACK"); throw error; }
41 },
42 } as unknown as D1Database;
43 }
44
45 async function privateKeyPEM(): Promise<string> {
46 const pair = await crypto.subtle.generateKey(
47 { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
48 true,
49 ["sign", "verify"],
50 ) as CryptoKeyPair;
51 const bytes = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey) as ArrayBuffer);
52 let binary = "";
53 for (const byte of bytes) binary += String.fromCharCode(byte);
54 return `-----BEGIN PRIVATE KEY-----\n${btoa(binary).match(/.{1,64}/g)?.join("\n") ?? ""}\n-----END PRIVATE KEY-----`;
55 }
56
57 async function envFor(db: DatabaseSync): Promise<Env> {
58 return {
59 DB: sqliteD1(db), CRASH_STORAGE_MODE: "firebase",
60 FIREBASE_DATABASE_URL: "https://reasonix-test.asia-southeast1.firebasedatabase.app",
61 FIREBASE_CLIENT_EMAIL: "writer@example.iam.gserviceaccount.com",
62 FIREBASE_PRIVATE_KEY: await privateKeyPEM(),
63 } as Env;
64 }
65
66 function firebaseStore(initial: Record<string, unknown>) {
67 const values = new Map(Object.entries(initial));
68 const versions = new Map<string, number>();
69 const pathOf = (url: string) => new URL(url).pathname.replace(/^\//, "").replace(/\.json$/, "");
70 const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
71 const url = String(input);
72 if (url === "https://oauth2.googleapis.com/token") {
73 return Response.json({ access_token: "token", expires_in: 3600 });
74 }
75 const path = pathOf(url);
76 const method = init?.method ?? "GET";
77 const version = versions.get(path) ?? 1;
78 if (method === "GET") return Response.json(values.get(path) ?? null, { headers: { etag: `"${version}"` } });
79 if (new Headers(init?.headers).get("If-Match") !== `"${version}"`) return new Response(null, { status: 412 });
80 if (method === "DELETE") {
81 for (const key of [...values.keys()]) if (key === path || key.startsWith(`${path}/`)) values.delete(key);
82 } else {
83 values.set(path, JSON.parse(String(init?.body)) as unknown);
84 }
85 versions.set(path, version + 1);
86 return new Response(null, { status: 204 });
87 });
88 return { values, fetcher };
89 }
90
91 function insertGroup(db: DatabaseSync, fingerprint: string, status: string, lastSeen: string) {
92 db.prepare(`INSERT INTO groups (
93 fingerprint, kind, count, first_seen, last_seen, first_version, last_version,
94 status, title, source, severity
95 ) VALUES (?, 'crash', 8, '2026-01-01T00:00:00Z', ?, 'v1', 'v2', ?, 'boom', 'go', 'high')`)
96 .run(fingerprint, lastSeen, status);
97 db.prepare(`INSERT INTO firebase_crash_group_state (
98 fingerprint, reserved_bytes, last_seen
99 ) VALUES (?, ?, ?)`).run(fingerprint, FIREBASE_ACTIVE_RESERVATION_BYTES, lastSeen);
100 }
101
102 afterEach(() => { vi.unstubAllGlobals(); resetFirebaseAuthForTests(); });
103
104 describe("Firebase sample lifecycle", () => {
105 it("compacts at 30 days, tombstones at 60 days, and deletes after 24 hours", async () => {
106 const db = new DatabaseSync(":memory:");
107 db.exec(freshSchemaSQL);
108 const fingerprint = "a".repeat(64);
109 insertGroup(db, fingerprint, "resolved", "2026-07-10T00:00:00Z");
110 const root = `groups/${fingerprint}`;
111 const old = { eventId: "1".repeat(32), receivedAt: "2026-01-01T00:00:00Z", groupCount: 1, writerGeneration: 0, sampleEpoch: 1 };
112 const firebase = firebaseStore({
113 [`${root}/meta`]: { count: 8, writerGeneration: 0, sampleEpoch: 1 },
114 [`${root}/samples/first`]: old,
115 ...Object.fromEntries(Array.from({ length: 5 }, (_, slot) => [`${root}/samples/latest/${slot}`, old])),
116 [root]: { present: true },
117 });
118 vi.stubGlobal("fetch", firebase.fetcher);
119 const env = await envFor(db);
120 try {
121 await runFirebaseCrashLifecycle(env, new Date("2026-08-25T00:00:00Z"));
122 expect(db.prepare("SELECT sample_state, reserved_bytes FROM firebase_crash_group_state").get())
123 .toEqual({ sample_state: "compacted", reserved_bytes: FIREBASE_COMPACTED_RESERVATION_BYTES });
124 expect(firebase.values.get(`${root}/samples/latest/0`)).toMatchObject({ marker: "compacted" });
125 expect(firebase.values.get(`${root}/samples/first`)).toEqual(old);
126
127 db.prepare("UPDATE groups SET last_seen = '2026-06-01T00:00:00Z' WHERE fingerprint = ?").run(fingerprint);
128 await runFirebaseCrashLifecycle(env, new Date("2026-08-25T01:00:00Z"));
129 expect(db.prepare("SELECT sample_state, reserved_bytes FROM firebase_crash_group_state").get())
130 .toEqual({ sample_state: "archiving", reserved_bytes: FIREBASE_ARCHIVING_RESERVATION_BYTES });
131 expect(firebase.values.get(`${root}/samples/first`)).toMatchObject({ marker: "archiving" });
132
133 await runFirebaseCrashLifecycle(env, new Date("2026-08-26T02:00:00Z"));
134 expect(db.prepare("SELECT sample_state, reserved_bytes FROM firebase_crash_group_state").get())
135 .toEqual({ sample_state: "archived", reserved_bytes: 0 });
136 expect([...firebase.values.keys()].some((key) => key === root || key.startsWith(`${root}/`))).toBe(false);
137 } finally { db.close(); }
138 });
139
140 it("never cleans open groups or groups with pending outbox, and reactivates an archived epoch", async () => {
141 const db = new DatabaseSync(":memory:");
142 db.exec(freshSchemaSQL);
143 const open = "b".repeat(64);
144 const pending = "c".repeat(64);
145 insertGroup(db, open, "open", "2020-01-01T00:00:00Z");
146 insertGroup(db, pending, "ignored", "2020-01-01T00:00:00Z");
147 db.prepare(`INSERT INTO firebase_crash_outbox (
148 event_id, fingerprint, payload, state, attempts, next_attempt_at, created_at, updated_at
149 ) VALUES (?, ?, '{}', 'queued', 0, ?, ?, ?)`).run(
150 "9".repeat(32), pending, "2026-08-25T00:00:00Z", "2026-08-25T00:00:00Z", "2026-08-25T00:00:00Z",
151 );
152 const env = await envFor(db);
153 vi.stubGlobal("fetch", firebaseStore({}).fetcher);
154 try {
155 await runFirebaseCrashLifecycle(env, new Date("2026-08-25T00:00:00Z"));
156 expect(db.prepare("SELECT COUNT(*) AS count FROM firebase_crash_group_state WHERE sample_state = 'active'").get())
157 .toEqual({ count: 2 });
158 db.prepare(`UPDATE firebase_crash_group_state SET
159 sample_state = 'archived', reserved_bytes = 0, sample_epoch = 3
160 WHERE fingerprint = ?`).run(open);
161 expect(await reserveFirebaseGroup(env, open, "2026-08-25T01:00:00Z")).toBe("reserved");
162 expect(db.prepare("SELECT sample_state, sample_epoch, reserved_bytes FROM firebase_crash_group_state WHERE fingerprint = ?").get(open))
163 .toEqual({ sample_state: "active", sample_epoch: 4, reserved_bytes: FIREBASE_ACTIVE_RESERVATION_BYTES });
164 } finally { db.close(); }
165 });
166 });
167
167 lines TYPESCRIPT