source: Klonkt/test/push.test.js@ ad10715

main
Last change on this file since ad10715 was ad10715, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: web push slice 1, VAPID keys + subscription store

The foundation for background notifications (docs/webpush-design.md).

  • Dependency (approved): web-push for RFC 8292 VAPID JWTs and RFC 8291 payload encryption. Lazy import so a canary that autofollows before npm ci never crashes on boot (same pattern as @simplewebauthn/server).
  • VAPID keys: env (VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT) wins, else auto-generated once into storage/.vapid (0600), never regenerated while the file exists: new keys would invalidate every subscription. Subject: PUBLIC_BASE_URL, else mailto from SMTP_FROM.
  • push_subscriptions table: one row per device, client keys for encrypted payloads, per-type alert preferences (follow/reply on, like/boost off, dm on by default), self-pruning on 404/410 in the send path.
  • notifyUser/notifySite: honour alert prefs, cap title/body length, fire-and-forget at call sites (slice 3 wires the triggers).

Changed files:
package.json, package-lock.json

  • web-push@3.6.7

src/config/database.js

  • push_subscriptions table (additive)

src/routes/posts.js

  • RESERVED_SLUGS: add 'push' (and the missing 'paid') so a post can't shadow the mounted routes

New file:
src/services/PushService.js

  • VAPID key resolve/persist, subscription CRUD, encrypted send with pruning, notifyUser/notifySite

test/push.test.js

  • key autogen (0600, persists, served=stored), subscription CRUD, upsert-not-duplicate, refuse incomplete payloads

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 3.2 KB
Line 
1// Web Push slice 1: VAPID key management + the subscription store. Actual
2// delivery needs a real push service, so tests cover the pure parts: key
3// auto-generation/persistence (no env editing) and the subscription CRUD with
4// per-type alert preferences. Own process (node --test), so env tweaks here
5// don't leak into other test files.
6import { test } from 'node:test';
7import assert from 'node:assert/strict';
8import fs from 'fs';
9import os from 'os';
10import path from 'path';
11
12delete process.env.VAPID_PUBLIC_KEY;
13delete process.env.VAPID_PRIVATE_KEY;
14const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pushkey-'));
15process.env.DATABASE_PATH = path.join(dir, 'database.sqlite');
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const Push = (await import('../src/services/PushService.js')).default;
21const keyFile = path.join(dir, '.vapid');
22
23test('VAPID keys auto-generate to a 0600 file and persist', async () => {
24 const pub = await Push.publicKey();
25 assert.ok(pub && typeof pub === 'string' && pub.length > 20, 'public key exists');
26 assert.ok(fs.existsSync(keyFile), 'key file written next to the database');
27 if (process.platform !== 'win32') {
28 assert.equal(fs.statSync(keyFile).mode & 0o777, 0o600, 'key file is 0600');
29 }
30 const onDisk = JSON.parse(fs.readFileSync(keyFile, 'utf8'));
31 assert.equal(onDisk.publicKey, pub, 'served key matches the persisted one');
32 assert.equal(await Push.pushReady(), true);
33});
34
35test('subscription store: save, list, update alerts, delete', () => {
36 const ok = Push.saveSubscription({
37 endpoint: 'https://push.example/ep1', userId: 'u1',
38 p256dh: 'PK', auth: 'AUTH', uaLabel: 'Firefox op laptop',
39 });
40 assert.equal(ok, true);
41 const list = Push.listSubscriptions('u1');
42 assert.equal(list.length, 1);
43 const alerts = JSON.parse(list[0].alert_types);
44 assert.equal(alerts.follow, 1); // defaults applied
45 assert.equal(alerts.like, 0);
46 // update preferences
47 assert.equal(Push.updateAlerts('https://push.example/ep1', 'u1', { like: 1, follow: 0 }), true);
48 const upd = JSON.parse(Push.listSubscriptions('u1')[0].alert_types);
49 assert.equal(upd.like, 1);
50 assert.equal(upd.follow, 0);
51 assert.equal(upd.dm, 1); // untouched default survives
52 // wrong user can't update
53 assert.equal(Push.updateAlerts('https://push.example/ep1', 'u2', { like: 0 }), false);
54 // delete
55 assert.equal(Push.deleteSubscription('https://push.example/ep1'), true);
56 assert.equal(Push.listSubscriptions('u1').length, 0);
57});
58
59test('re-subscribing the same endpoint upserts instead of duplicating', () => {
60 Push.saveSubscription({ endpoint: 'https://push.example/ep2', userId: 'u1', p256dh: 'A', auth: 'B' });
61 Push.saveSubscription({ endpoint: 'https://push.example/ep2', userId: 'u1', p256dh: 'C', auth: 'D' });
62 const rows = db.prepare('SELECT * FROM push_subscriptions WHERE endpoint = ?').all('https://push.example/ep2');
63 assert.equal(rows.length, 1);
64 assert.equal(rows[0].p256dh, 'C');
65});
66
67test('incomplete subscription payloads are refused', () => {
68 assert.equal(Push.saveSubscription({ endpoint: '', userId: 'u1', p256dh: 'x', auth: 'y' }), false);
69 assert.equal(Push.saveSubscription({ endpoint: 'https://e', userId: 'u1', p256dh: '', auth: 'y' }), false);
70});
Note: See TracBrowser for help on using the repository browser.