source: Klonkt/test/paid-patreon.test.js

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

Feature: paid posts slice 1, owner Patreon config (encrypted)

The site owner can connect their OWN Patreon campaign for paid posts
(klonkt-demo-aki), premium-gated in Beheer. Client id/secret, campaign
id and the creator access/refresh token are stored ENCRYPTED at rest
(new CryptoBox AES-256-GCM helper, key from PAID_SECRET), so a database
dump leaks nothing usable; the token auto-refreshes. Separate from
Klonkt Premium's license flow, which is untouched. Degrades gracefully:
without PAID_SECRET the admin page refuses to save rather than storing
plaintext. Nothing patron-facing yet (posts.paid + unlock come in
slices 2 to 4), so no changelog entry.

CryptoBox also carries the cookie-less signed-blob helper (signBlob/
verifyBlob) that slices 3 and 4 reuse for the OAuth state and the
WebAuthn challenge.

Changed files:
src/config/database.js

  • paid_patreon table (site_id PK, secrets encrypted)

src/server.js

  • mount /admin/paid

src/views/pages/admin.ejs

  • "Betaalde posts" button in Beheer

New file:
src/services/CryptoBox.js

  • aes-256-gcm encrypt/decrypt + HMAC signBlob/verifyBlob

src/services/PaidPatreonService.js

  • owner config CRUD (encrypted), token refresh, creatorAccessToken

src/routes/admin-paid.js

  • premium-gated config form (GET/POST/disconnect)

src/views/pages/admin-paid.ejs

  • the form + status

test/paid-patreon.test.js

  • crypto roundtrip, no-plaintext-in-DB, refresh, blob signing

docs/paid-posts-design.md, docs/privacy-betaalde-posts.md

  • concurrency property documented

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

  • Property mode set to 100644
File size: 4.2 KB
Line 
1// Paid posts slice 1 (klonkt-demo-aki): owner Patreon config is stored with the
2// creator token encrypted at rest, and refreshes. In-memory SQLite.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5
6process.env.DATABASE_PATH = ':memory:';
7process.env.PUBLIC_BASE_URL = 'https://test.example';
8process.env.PAID_SECRET = 'a-test-paid-secret-of-sufficient-length';
9
10const dbMod = await import('../src/config/database.js');
11const db = dbMod.default;
12dbMod.initializeDatabase();
13const PP = (await import('../src/services/PaidPatreonService.js')).default;
14const { encrypt, decrypt, signBlob, verifyBlob } = await import('../src/services/CryptoBox.js');
15
16test('CryptoBox roundtrips and rejects tampering', () => {
17 const c = encrypt('super-secret-token');
18 assert.notEqual(c, 'super-secret-token');
19 assert.equal(decrypt(c), 'super-secret-token');
20 const parts = c.split(':'); parts[2] = Buffer.from('tampered').toString('base64');
21 assert.throws(() => decrypt(parts.join(':')));
22});
23
24test('signBlob/verifyBlob: valid passes, tampered and expired fail', () => {
25 const t = signBlob({ site: 's1', purpose: 'link', cents: 500 }, 600);
26 const p = verifyBlob(t);
27 assert.equal(p.site, 's1'); assert.equal(p.purpose, 'link'); assert.equal(p.cents, 500);
28 assert.equal(verifyBlob(t.slice(0, -2) + 'xx'), null); // bad tag
29 assert.equal(verifyBlob(signBlob({ x: 1 }, -1)), null); // already expired
30});
31
32test('owner config stores the creator secret + token ENCRYPTED, never plaintext', () => {
33 PP.saveOwnerConfig('s1', {
34 clientId: 'cid', clientSecret: 'the-secret', campaignId: '42',
35 accessToken: 'acc-token', refreshToken: 'ref-token',
36 tokenExp: Math.floor(Date.now() / 1000) + 3600, defaultMinCents: 500,
37 });
38 // Raw DB row must not contain the plaintext secret/token.
39 const raw = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get('s1');
40 const dump = JSON.stringify(raw);
41 assert.ok(!dump.includes('the-secret'), 'client secret leaked in plaintext');
42 assert.ok(!dump.includes('acc-token'), 'access token leaked in plaintext');
43 assert.ok(!dump.includes('ref-token'), 'refresh token leaked in plaintext');
44 // But the service decrypts it back.
45 const c = PP.getOwnerConfig('s1');
46 assert.equal(c.clientSecret, 'the-secret');
47 assert.equal(c.accessToken, 'acc-token');
48 assert.equal(c.campaignId, '42');
49 assert.equal(c.defaultMinCents, 500);
50});
51
52test('ownerStatus never exposes secrets', () => {
53 const st = PP.ownerStatus('s1');
54 assert.equal(st.configured, true);
55 assert.equal(st.connected, true);
56 assert.equal(JSON.stringify(st).includes('the-secret'), false);
57 assert.equal(JSON.stringify(st).includes('acc-token'), false);
58});
59
60test('re-saving without a secret keeps the old one (no re-paste needed)', () => {
61 PP.saveOwnerConfig('s1', { defaultMinCents: 999 });
62 const c = PP.getOwnerConfig('s1');
63 assert.equal(c.clientSecret, 'the-secret'); // preserved
64 assert.equal(c.defaultMinCents, 999); // updated
65});
66
67test('refreshCreatorToken stores the new token (encrypted) via injected fetch', async () => {
68 let called = null;
69 const fakeFetch = async (url, opts) => {
70 called = { url, body: opts.body };
71 return { ok: true, json: async () => ({ access_token: 'new-acc', refresh_token: 'new-ref', expires_in: 2592000 }) };
72 };
73 const ok = await PP.refreshCreatorToken('s1', fakeFetch);
74 assert.equal(ok, true);
75 assert.ok(called.url.includes('patreon.com'));
76 assert.ok(called.body.includes('grant_type=refresh_token'));
77 const c = PP.getOwnerConfig('s1');
78 assert.equal(c.accessToken, 'new-acc');
79 assert.equal(c.refreshToken, 'new-ref');
80 // and still encrypted on disk
81 const raw = db.prepare('SELECT access_token_enc FROM paid_patreon WHERE site_id = ?').get('s1');
82 assert.ok(!raw.access_token_enc.includes('new-acc'));
83});
84
85test('needsRefresh true when near expiry, false when fresh', () => {
86 PP.saveOwnerConfig('s2', { clientId: 'c', clientSecret: 's', refreshToken: 'r', accessToken: 'a', tokenExp: Math.floor(Date.now()/1000) + 60 });
87 assert.equal(PP.needsRefresh('s2'), true); // 60s < 1h skew
88 PP.saveOwnerConfig('s2', { tokenExp: Math.floor(Date.now()/1000) + 7200 });
89 assert.equal(PP.needsRefresh('s2'), false);
90});
Note: See TracBrowser for help on using the repository browser.