// API tests for the Web Push subsystem: // - GET /api/push/vapid-public-key // - POST /api/push/subscribe (auth + upsert) // - POST /api/push/unsubscribe (auth + removal) // - account-switch row reassignment for the same browser endpoint // // The full delivery path (web-push -> 410 cleanup) is exercised via a // direct DB-level simulation: we insert a subscription with a known // endpoint, then assert it can be looked up + removed through the // public API. The 410 cleanup itself runs in the server when push // providers reject — covered by reading the helper from // `dist/index.mjs` would require a separate integration runner, so // here we cover the DB/HTTP contract. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; const createdEndpoints = []; function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix) { const username = `${prefix}_${uniq()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], ); const id = rows[0].id; createdUserIds.push(id); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [id], ); return { id, username }; } async function login(username) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: TEST_PASSWORD }), }); assert.equal(res.status, 200, `login expected 200, got ${res.status}`); const setCookie = res.headers.get("set-cookie"); const sid = setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")); assert.ok(sid, "expected connect.sid cookie"); return sid; } function fakeSub(suffix) { const endpoint = `https://fcm.example.test/push/${suffix}_${uniq()}`; createdEndpoints.push(endpoint); return { endpoint, keys: { p256dh: "BPubKey-" + suffix, auth: "auth-" + suffix, }, }; } before(async () => { // Sanity: server up? const r = await fetch(`${API_BASE}/api/healthz`).catch(() => null); if (!r || !r.ok) { throw new Error(`API at ${API_BASE} is not reachable`); } }); after(async () => { if (createdEndpoints.length > 0) { await pool.query( `DELETE FROM push_subscriptions WHERE endpoint = ANY($1::text[])`, [createdEndpoints], ); } if (createdUserIds.length > 0) { await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ createdUserIds, ]); } await pool.end(); }); test("GET /api/push/vapid-public-key returns a base64url key", async () => { const res = await fetch(`${API_BASE}/api/push/vapid-public-key`); assert.equal(res.status, 200); const body = await res.json(); assert.ok( typeof body.publicKey === "string" && body.publicKey.length > 40, "expected non-empty VAPID public key", ); }); test("POST /api/push/subscribe requires auth", async () => { const res = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(fakeSub("noauth")), }); assert.equal(res.status, 401); }); test("POST /api/push/unsubscribe requires auth", async () => { const res = await fetch(`${API_BASE}/api/push/unsubscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: "https://example.test/x" }), }); assert.equal(res.status, 401); }); test("subscribe persists a row and unsubscribe removes it", async () => { const user = await createUser("push_basic"); const cookie = await login(user.username); const sub = fakeSub("basic"); const subRes = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(sub), }); assert.equal(subRes.status, 200); const rowsAfter = await pool.query( `SELECT user_id, p256dh, auth FROM push_subscriptions WHERE endpoint = $1`, [sub.endpoint], ); assert.equal(rowsAfter.rowCount, 1, "expected one row after subscribe"); assert.equal(rowsAfter.rows[0].user_id, user.id); assert.equal(rowsAfter.rows[0].p256dh, sub.keys.p256dh); assert.equal(rowsAfter.rows[0].auth, sub.keys.auth); const unsubRes = await fetch(`${API_BASE}/api/push/unsubscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ endpoint: sub.endpoint }), }); assert.equal(unsubRes.status, 200); const rowsGone = await pool.query( `SELECT 1 FROM push_subscriptions WHERE endpoint = $1`, [sub.endpoint], ); assert.equal(rowsGone.rowCount, 0, "expected row removed after unsubscribe"); }); test("subscribe is idempotent — re-subscribing updates keys without duplicating", async () => { const user = await createUser("push_idem"); const cookie = await login(user.username); const sub = fakeSub("idem"); for (const variant of [sub, { ...sub, keys: { p256dh: "rotated", auth: "rotated" } }]) { const res = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(variant), }); assert.equal(res.status, 200); } const rows = await pool.query( `SELECT p256dh, auth FROM push_subscriptions WHERE endpoint = $1`, [sub.endpoint], ); assert.equal(rows.rowCount, 1); assert.equal(rows.rows[0].p256dh, "rotated"); assert.equal(rows.rows[0].auth, "rotated"); }); test("subscribe reassigns endpoint to the new user when a different user signs in on the same browser", async () => { const userA = await createUser("push_a"); const userB = await createUser("push_b"); const cookieA = await login(userA.username); const cookieB = await login(userB.username); const sub = fakeSub("switch"); let res = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookieA }, body: JSON.stringify(sub), }); assert.equal(res.status, 200); res = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookieB }, body: JSON.stringify(sub), }); assert.equal(res.status, 200); const rows = await pool.query( `SELECT user_id FROM push_subscriptions WHERE endpoint = $1`, [sub.endpoint], ); assert.equal(rows.rowCount, 1, "endpoint should never duplicate across users"); assert.equal( rows.rows[0].user_id, userB.id, "endpoint should now belong to the most recent signer-in", ); }); test("subscribe rejects malformed input", async () => { const user = await createUser("push_bad"); const cookie = await login(user.username); const res = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ endpoint: "not-a-url", keys: {} }), }); assert.equal(res.status, 400); }); test("DELETE /api/push/subscribe?endpoint=... removes the row (spec alias)", async () => { const user = await createUser("push_del"); const cookie = await login(user.username); const sub = fakeSub("del"); const subRes = await fetch(`${API_BASE}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(sub), }); assert.equal(subRes.status, 200); const before = await pool.query( `SELECT 1 FROM push_subscriptions WHERE endpoint = $1`, [sub.endpoint], ); assert.equal(before.rowCount, 1); const delRes = await fetch( `${API_BASE}/api/push/subscribe?endpoint=${encodeURIComponent(sub.endpoint)}`, { method: "DELETE", headers: { Cookie: cookie } }, ); assert.equal(delRes.status, 200); const after = await pool.query( `SELECT 1 FROM push_subscriptions WHERE endpoint = $1`, [sub.endpoint], ); assert.equal(after.rowCount, 0, "DELETE alias must remove the subscription row"); }); test("DELETE /api/push/subscribe requires auth and a valid endpoint", async () => { const noAuth = await fetch( `${API_BASE}/api/push/subscribe?endpoint=https://x.example/abc`, { method: "DELETE" }, ); assert.equal(noAuth.status, 401); const user = await createUser("push_del_bad"); const cookie = await login(user.username); const bad = await fetch( `${API_BASE}/api/push/subscribe?endpoint=not-a-url`, { method: "DELETE", headers: { Cookie: cookie } }, ); assert.equal(bad.status, 400); });