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