d78818b88a
Task #554. Adds a full Web Push stack on top of the existing Socket.IO notification fan-out so the iPad PWA (and any installed browser) receives system notifications when Tx OS is closed or backgrounded. Backend - New `push_subscriptions` table (userId + unique endpoint + p256dh/auth keys + ua + timestamps), exported from `@workspace/db`. - `artifacts/api-server/src/lib/push.ts`: - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT (Docker volume) with /tmp fallback for Replit dev, else ephemeral. - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs (orders/meetings/notes), prunes 404/410 endpoints, truncates payload bodies to ~3500 bytes. - **De-dup gate:** skips push when the user has any active Socket.IO connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a connected user only gets the in-app chime, never a duplicate system notification. - `upsertSubscription()` deletes a stale row first when the same browser endpoint flips to a different user (shared device) so the previous user's notifications can't leak. - Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`, `POST /api/push/unsubscribe`, and a spec-aligned alias `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated). - Push hooked into 4 existing emit sites: service-orders `notifyUser`, notes new-note + reply, executive-meeting broadcast. Frontend - `artifacts/tx-os/public/sw.js`: push + notificationclick only (no asset caching). All URLs (icon, badge, navigation target) resolved against `self.registration.scope`, so the SW works at root or under a subpath without code changes. - SW registration in `main.tsx` scoped to `BASE_URL`, gated on `serviceWorker` AND `PushManager` support so older browsers no-op cleanly. - `use-push-subscription` hook (enable/disable/refresh + status). - New `PushEnablePrompt` card mounted in `App` — appears on first launch when supported + permission still "default", one-tap enable, dismiss persists for 14 days. Silent on unsupported devices. - `PushToggleRow` added inside Notification Settings. - ar/en strings: `notifSettings.push.*` and `common.later`. Plumbing - OpenAPI: 3 new operations under `notifications`; orval codegen run. - `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT through to the api service. - `pnpm --filter @workspace/db run push` applied the schema. - web-push + @types/web-push installed in api-server. Verification - API restarts clean; `/api/push/vapid-public-key` returns the key; subscribe endpoint 401s without auth. - New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing — covers VAPID endpoint, auth gating on subscribe/unsubscribe, row persistence + removal, idempotent re-subscribe with key rotation, account-switch endpoint reassignment, and malformed-input rejection. - New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3 passing — true in-process unit test (`node --import tsx --experimental-test-module-mocks`) that mocks `web-push` to verify the prune path: 410 deletes the row, 404 deletes the row, and a transient 500 leaves the row intact. `tsx` added as a devDep and the test:run script picks up `*.test.ts` alongside the existing `.mjs` suites. - OpenAPI updated: `DELETE /push/subscribe` documented with an `endpoint` query parameter alongside the existing POST routes; orval codegen re-run so the generated client + zod schemas stay in sync. - Restored the unrelated serial tests (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`) that an earlier cleanup pass dropped — they are unchanged from prior green state. - Architect rounds addressed: 1. race + payload size. 2. dedup gate + first-launch UX + SW base-path + initial tests. 3. DELETE alias + PushManager check + restored serial tests + real 410/404 cleanup test + OpenAPI parity. 4. .env.docker.example + replit.md operational docs for VAPID, DELETE-alias integration tests (auth + happy-path + bad input), and an explicit design-note comment in `push.ts` documenting the intentional user-wide dedup policy.
281 lines
9.2 KiB
JavaScript
281 lines
9.2 KiB
JavaScript
// 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);
|
|
});
|