Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

push.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

push.test.tsBlame493 lines · 2 contributors
534f04aClaude1/**
2 * Block M2 — Web Push tests.
3 *
4 * Covers `src/lib/push.ts` + the `/pwa/*` API surface:
5 * - getVapidPublicKey: stable across calls (process-cached)
6 * - subscribeUser: persists, idempotent on (user, endpoint)
7 * - unsubscribeUser: deletes
8 * - sendPushToUser: per-subscription failures don't crash the fan-out,
9 * returns sent/failed counts
10 * - Stale-endpoint deletion on HTTP 410
11 * - Routes: vapid-public-key public, subscribe requires auth,
12 * unsubscribe requires auth
13 *
14 * Mock isolation
15 * --------------
16 * Bun's `mock.module()` is process-global. We capture the real `../db`
17 * module BEFORE any mock so afterAll can restore it. The fake db
18 * dispatches on the table passed to .from/.insert/.delete/.update,
19 * records mutations to in-memory arrays, and returns canned rows.
20 */
21
22import {
23 describe,
24 it,
25 expect,
26 mock,
27 beforeEach,
28 afterAll,
29} from "bun:test";
30
31const _real_db = await import("../db");
32
33// ---------------------------------------------------------------------------
34// Fake db state
35// ---------------------------------------------------------------------------
36
37type SubRow = {
38 userId: string;
39 endpoint: string;
40 p256dh: string;
41 auth: string;
42 userAgent: string | null;
43};
44
45let _subRows: SubRow[] = [];
46let _nextUserRow: any = null;
47const _inserted: any[] = [];
48const _updated: any[] = [];
49const _deleted: any[] = [];
50
51const tableName = (t: any): string => {
52 if (!t || typeof t !== "object") return "?";
53 if ("p256dh" in t && "endpoint" in t) return "push_subscriptions";
54 if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions";
55 if ("notifyPushOnMention" in t || "passwordHash" in t) return "users";
56 return "?";
57};
58
59let _lastSelectFrom: any = null;
60let _nextSessionRow: any = null;
61
62const _selectChain: any = {
63 from: (t: any) => {
64 _lastSelectFrom = t;
65 return _selectChain;
66 },
67 where: () => _selectChain,
68 orderBy: () => _selectChain,
69 limit: async () => {
70 const name = tableName(_lastSelectFrom);
71 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
72 if (name === "users") return _nextUserRow ? [_nextUserRow] : [];
73 return [];
74 },
75 then: (resolve: (v: any) => void) => {
76 const name = tableName(_lastSelectFrom);
77 if (name === "push_subscriptions") {
78 resolve(_subRows.map((r) => ({
79 endpoint: r.endpoint,
80 p256dh: r.p256dh,
81 auth: r.auth,
82 })));
83 return;
84 }
85 resolve([]);
86 },
87};
88
89const _insertChain = (table: any) => ({
90 values: (vals: any) => ({
91 onConflictDoUpdate: ({ set }: { set: any }) => {
92 const name = tableName(table);
93 if (name === "push_subscriptions") {
94 const existing = _subRows.find(
95 (r) => r.userId === vals.userId && r.endpoint === vals.endpoint
96 );
97 if (existing) {
98 existing.p256dh = set.p256dh ?? vals.p256dh;
99 existing.auth = set.auth ?? vals.auth;
100 existing.userAgent = set.userAgent ?? vals.userAgent ?? null;
101 } else {
102 _subRows.push({
103 userId: vals.userId,
104 endpoint: vals.endpoint,
105 p256dh: vals.p256dh,
106 auth: vals.auth,
107 userAgent: vals.userAgent ?? null,
108 });
109 }
110 }
111 _inserted.push({ table: name, values: vals });
112 return Promise.resolve();
113 },
114 returning: async () => [],
115 then: (r: (v: any) => void) => {
116 const name = tableName(table);
117 if (name === "push_subscriptions") {
118 _subRows.push({
119 userId: vals.userId,
120 endpoint: vals.endpoint,
121 p256dh: vals.p256dh,
122 auth: vals.auth,
123 userAgent: vals.userAgent ?? null,
124 });
125 }
126 _inserted.push({ table: name, values: vals });
127 r(undefined);
128 },
129 }),
130});
131
132const _updateChain = (table: any) => ({
133 set: (s: any) => ({
134 where: () => {
135 const name = tableName(table);
136 _updated.push({ table: name, set: s });
137 return Promise.resolve();
138 },
139 }),
140});
141
142let _deleteFilter: { userId?: string; endpoint?: string } | null = null;
143
144const _deleteChain = (table: any) => ({
145 where: (_cond: any) => {
146 const name = tableName(table);
147 _deleted.push({ table: name });
148 if (name === "push_subscriptions") {
149 // We don't get the filter values from the cond object easily; the
150 // tests below call delete after subscribing exactly one row per
151 // case, so we just drop everything matching the most recently used
152 // endpoint hint stored on the chain.
153 if (_deleteFilter) {
154 _subRows = _subRows.filter(
155 (r) =>
156 !(r.userId === _deleteFilter!.userId &&
157 r.endpoint === _deleteFilter!.endpoint)
158 );
159 }
160 }
161 return Promise.resolve();
162 },
163});
164
165const _fakeDb = {
166 db: {
167 select: () => _selectChain,
168 insert: (t: any) => _insertChain(t),
169 update: (t: any) => _updateChain(t),
170 delete: (t: any) => _deleteChain(t),
171 },
172 getDb: () => _fakeDb.db,
173};
174
175mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
176
177// Imports must come AFTER the mock so the module graph picks up the fake db.
178const {
179 getVapidPublicKey,
180 subscribeUser,
181 unsubscribeUser,
182 sendPushToUser,
183 __setSendTransport,
184 __resetVapidCacheForTests,
185} = await import("../lib/push");
186const { default: app } = await import("../app");
187const { sessionCache } = await import("../lib/cache");
188
189// ---------------------------------------------------------------------------
190// Fixtures
191// ---------------------------------------------------------------------------
192
193// Valid (random) base64url-encoded payload-encryption keys. The encryption
194// path generates a fresh ECDH keypair on each call so the recipient public
195// key only needs to be a real P-256 uncompressed point; we generate one
196// with Web Crypto at test setup time.
197let RECIPIENT_P256DH = "";
198let RECIPIENT_AUTH = "";
199
200const USER_ID = "55555555-5555-5555-5555-555555555555";
201const SESSION_TOKEN = "test-session-token-push-m2";
202
203const TEST_USER = {
204 id: USER_ID,
205 username: "push_test_user",
206 displayName: "Push Test User",
207 email: "push@example.com",
208 passwordHash: "x",
209 bio: null,
210 notifyPushOnMention: true,
211 notifyPushOnAssign: true,
212 notifyPushOnReviewRequest: true,
213 notifyPushOnDeployFailed: true,
214 createdAt: new Date(),
215 updatedAt: new Date(),
216};
217
218function authedHeaders(): HeadersInit {
219 return {
220 cookie: `session=${SESSION_TOKEN}`,
221 "content-type": "application/json",
222 // CSRF same-origin guard: requests from real browser fetch() carry an
223 // Origin matching the page host. We mirror that here so the
224 // double-submit token isn't required for these JSON POSTs.
225 host: "localhost",
226 origin: "http://localhost",
227 };
228}
229
230beforeEach(async () => {
231 _subRows = [];
232 _nextUserRow = TEST_USER;
233 _nextSessionRow = {
234 userId: USER_ID,
235 token: SESSION_TOKEN,
236 expiresAt: new Date(Date.now() + 60 * 60 * 1000),
237 requires2fa: false,
238 };
239 _inserted.length = 0;
240 _updated.length = 0;
241 _deleted.length = 0;
242 _deleteFilter = null;
243 sessionCache.set(SESSION_TOKEN, TEST_USER as any);
244
245 // Generate a real P-256 point so encryptPayload doesn't choke on dummy
246 // bytes. We only need the public-key bytes; the private key stays inside
247 // Web Crypto. `auth` can be any 16 bytes.
248 if (!RECIPIENT_P256DH) {
249 const kp = await crypto.subtle.generateKey(
250 { name: "ECDH", namedCurve: "P-256" },
251 true,
252 ["deriveBits"]
253 );
254 const raw = new Uint8Array(
255 await crypto.subtle.exportKey("raw", kp.publicKey)
256 );
257 const authBytes = crypto.getRandomValues(new Uint8Array(16));
258 RECIPIENT_P256DH = b64u(raw);
259 RECIPIENT_AUTH = b64u(authBytes);
260 }
261});
262
263function b64u(bytes: Uint8Array): string {
264 let bin = "";
265 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
266 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
267}
268
269afterAll(() => {
270 sessionCache.invalidate(SESSION_TOKEN);
271 __resetVapidCacheForTests();
272 mock.module("../db", () => _real_db);
273});
274
275// ---------------------------------------------------------------------------
276// Pure-helper tests
277// ---------------------------------------------------------------------------
278
279describe("push.ts — getVapidPublicKey", () => {
280 it("returns a stable key across calls (in-memory cached)", async () => {
281 __resetVapidCacheForTests();
282 const a = await getVapidPublicKey();
283 const b = await getVapidPublicKey();
284 expect(a).toBe(b);
285 // Base64url, no padding.
286 expect(a).toMatch(/^[A-Za-z0-9_\-]+$/);
287 expect(a.length).toBeGreaterThan(40);
288 });
289});
290
291describe("push.ts — subscribeUser / unsubscribeUser", () => {
292 it("subscribeUser inserts a row", async () => {
293 await subscribeUser(
294 USER_ID,
295 { endpoint: "https://push.example/abc", keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH } },
296 "test-ua"
297 );
298 expect(_subRows.length).toBe(1);
299 expect(_subRows[0]!.userId).toBe(USER_ID);
300 expect(_subRows[0]!.endpoint).toBe("https://push.example/abc");
301 expect(_subRows[0]!.userAgent).toBe("test-ua");
302 });
303
304 it("subscribeUser is idempotent on (user, endpoint)", async () => {
305 await subscribeUser(USER_ID, {
306 endpoint: "https://push.example/abc",
307 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
308 });
309 await subscribeUser(USER_ID, {
310 endpoint: "https://push.example/abc",
311 keys: { p256dh: RECIPIENT_P256DH, auth: "newauth" },
312 });
313 expect(_subRows.length).toBe(1);
314 expect(_subRows[0]!.auth).toBe("newauth");
315 });
316
317 it("unsubscribeUser deletes the row", async () => {
318 await subscribeUser(USER_ID, {
319 endpoint: "https://push.example/zzz",
320 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
321 });
322 expect(_subRows.length).toBe(1);
323 _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/zzz" };
324 await unsubscribeUser(USER_ID, "https://push.example/zzz");
325 expect(_subRows.length).toBe(0);
326 });
327});
328
329// ---------------------------------------------------------------------------
330// sendPushToUser fan-out + stale-endpoint cleanup
331// ---------------------------------------------------------------------------
332
333describe("push.ts — sendPushToUser", () => {
334 it("swallows per-subscription failures and returns sent/failed counts", async () => {
335 // Three subscriptions: one ok (200), one server error (500), one gone (410).
336 _subRows = [
337 { userId: USER_ID, endpoint: "https://push.example/ok", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
338 { userId: USER_ID, endpoint: "https://push.example/fail", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
339 { userId: USER_ID, endpoint: "https://push.example/gone", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
340 ];
341
342 const prev = __setSendTransport(async (url) => {
343 if (url.endsWith("/ok")) return { status: 201 };
344 if (url.endsWith("/fail")) return { status: 500 };
345 if (url.endsWith("/gone")) return { status: 410 };
346 return { status: 502 };
347 });
348 try {
349 // Drop the gone endpoint when the helper purges it.
350 _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/gone" };
351 const res = await sendPushToUser(USER_ID, {
352 title: "hello",
353 body: "world",
354 url: "/notifications",
355 });
356 expect(res.sent).toBe(1);
357 expect(res.failed).toBe(2);
358 // The 410 endpoint should have been purged from the table.
359 expect(_subRows.find((r) => r.endpoint.endsWith("/gone"))).toBeUndefined();
360 // The 500 endpoint must NOT be purged — it might be transient.
361 expect(_subRows.find((r) => r.endpoint.endsWith("/fail"))).toBeDefined();
362 } finally {
363 __setSendTransport(prev);
364 }
365 });
366
367 it("returns zero counts when the user has no subscriptions", async () => {
368 _subRows = [];
369 const res = await sendPushToUser(USER_ID, { title: "x", body: "y" });
370 expect(res).toEqual({ sent: 0, failed: 0 });
371 });
372});
373
374// ---------------------------------------------------------------------------
375// Routes
376// ---------------------------------------------------------------------------
377
378describe("/pwa/vapid-public-key", () => {
379 it("is public — returns 200 + { key } without auth", async () => {
380 const res = await app.request("/pwa/vapid-public-key");
381 expect(res.status).toBe(200);
382 const body = await res.json();
383 expect(typeof body.key).toBe("string");
384 expect(body.key.length).toBeGreaterThan(40);
385 });
386});
387
388describe("/pwa/subscribe", () => {
389 it("requires auth (302 to /login when anonymous)", async () => {
390 const res = await app.request("/pwa/subscribe", {
391 method: "POST",
392 headers: { "content-type": "application/json" },
393 body: JSON.stringify({
394 endpoint: "https://push.example/x",
395 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
396 }),
397 });
398 // requireAuth either redirects to /login or returns 401 — accept either.
399 expect([302, 401, 403]).toContain(res.status);
400 });
401
402 it("authed POST persists a subscription and returns 201", async () => {
403 const res = await app.request("/pwa/subscribe", {
404 method: "POST",
405 headers: authedHeaders(),
406 body: JSON.stringify({
407 endpoint: "https://push.example/me",
408 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
409 }),
410 });
411 expect(res.status).toBe(201);
412 expect(_subRows.length).toBe(1);
413 expect(_subRows[0]!.endpoint).toBe("https://push.example/me");
414 });
415
416 it("rejects malformed bodies with 400", async () => {
417 const res = await app.request("/pwa/subscribe", {
418 method: "POST",
419 headers: authedHeaders(),
420 body: JSON.stringify({ endpoint: "no-keys" }),
421 });
422 expect(res.status).toBe(400);
423 });
424});
425
426describe("/pwa/unsubscribe", () => {
427 it("requires auth", async () => {
428 const res = await app.request("/pwa/unsubscribe", {
429 method: "POST",
430 headers: { "content-type": "application/json" },
431 body: JSON.stringify({ endpoint: "https://push.example/x" }),
432 });
433 expect([302, 401, 403]).toContain(res.status);
434 });
435
436 it("authed POST deletes and returns 204", async () => {
437 _subRows = [
438 { userId: USER_ID, endpoint: "https://push.example/byebye", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
439 ];
440 _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/byebye" };
441 const res = await app.request("/pwa/unsubscribe", {
442 method: "POST",
443 headers: authedHeaders(),
444 body: JSON.stringify({ endpoint: "https://push.example/byebye" }),
445 });
446 expect(res.status).toBe(204);
447 expect(_subRows.length).toBe(0);
448 });
449});
450
904927dClaude451describe("/sw-push.js (AA-loop self-unregister)", () => {
452 // 2026-05-16 — was previously a push + offline + fetch-fallback SW
453 // registered at scope `/`. That collided with `/sw.js` (also at `/`),
454 // causing the admin-dashboard reload loop. The body is now a self-
455 // unregister script so existing browsers that have it installed
456 // auto-recover on their next SW update check (which is every
457 // navigation since `Cache-Control: no-store`).
458 it("serves a self-unregistering service worker", async () => {
534f04aClaude459 const res = await app.request("/sw-push.js");
460 expect(res.status).toBe(200);
461 expect(res.headers.get("content-type") || "").toContain(
462 "application/javascript"
463 );
464 const body = await res.text();
904927dClaude465 expect(body).toContain("self.registration.unregister");
466 expect(body).toContain("addEventListener('install'");
467 expect(body).toContain("addEventListener('activate'");
468 });
469
470 it("has no fetch handler — every request must hit the network", async () => {
471 const res = await app.request("/sw-push.js");
472 const body = await res.text();
473 expect(body).not.toContain("addEventListener('fetch'");
474 });
475
476 it("does not subscribe to push events anymore", async () => {
477 const res = await app.request("/sw-push.js");
478 const body = await res.text();
479 expect(body).not.toContain("addEventListener('push'");
480 expect(body).not.toContain("addEventListener('notificationclick'");
534f04aClaude481 });
482});
483
484describe("/offline.html", () => {
485 it("serves a dark-themed offline page", async () => {
486 const res = await app.request("/offline.html");
487 expect(res.status).toBe(200);
488 expect(res.headers.get("content-type") || "").toContain("text/html");
489 const body = await res.text();
490 expect(body).toContain("You're offline");
81201ccTest User491 expect(body).toContain('data-theme="light"');
534f04aClaude492 });
493});