source: Klonkt/test/push.test.js@ 96c714f

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

Feature: web push slice 4, burst throttle + docs

  • Burst throttle: a wave of likes or a mass-follow becomes one ping, not a wave of pushes. Per (user, type) at most one push per window (like/boost 300s, follow 60s, reply/dm 30s, test never throttled); extras drop silently — the events themselves still land in Berichten, only the ping is deduped. In-memory (one process; a restart costs at most one extra ping). Pure throttled() exported and pinned by test.
  • README: push notifications feature bullet, VAPID_* in the config table, storage/.vapid in the auto-generated-secrets + backup section (restoring without it silently breaks every subscription).
  • .env.example: VAPID block in the SESSION_SECRET/PAID_SECRET style.

Pruning (404/410 → row deleted) and the iOS install hint already landed in
slices 1-2; this closes the plan from docs/webpush-design.md.

Changed files:
src/services/PushService.js

  • throttled() + window table; notifyUser checks it first

test/push.test.js

  • throttle windows, per-type/per-user independence, test bypass

README.md

  • feature bullet, VAPID config row, backup warning

.env.example

  • VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT

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

  • Property mode set to 100644
File size: 3.9 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('burst throttle: one ping per window per (user,type); test type never throttles', () => {
68 assert.equal(Push.throttled('tu1', 'like', 1000), false); // first passes
69 assert.equal(Push.throttled('tu1', 'like', 1100), true); // within 300s window
70 assert.equal(Push.throttled('tu1', 'like', 1301), false); // window elapsed
71 assert.equal(Push.throttled('tu1', 'boost', 1000), false); // other type independent
72 assert.equal(Push.throttled('tu2', 'like', 1000), false); // other user independent
73 assert.equal(Push.throttled('tu1', 'test', 1000), false); // test bypasses
74 assert.equal(Push.throttled('tu1', 'test', 1001), false);
75});
76
77test('incomplete subscription payloads are refused', () => {
78 assert.equal(Push.saveSubscription({ endpoint: '', userId: 'u1', p256dh: 'x', auth: 'y' }), false);
79 assert.equal(Push.saveSubscription({ endpoint: 'https://e', userId: 'u1', p256dh: '', auth: 'y' }), false);
80});
Note: See TracBrowser for help on using the repository browser.