| 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.
|
|---|
| 3 | import { test } from 'node:test';
|
|---|
| 4 | import assert from 'node:assert/strict';
|
|---|
| 5 |
|
|---|
| 6 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 7 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 8 | process.env.PAID_SECRET = 'a-test-paid-secret-of-sufficient-length';
|
|---|
| 9 |
|
|---|
| 10 | const dbMod = await import('../src/config/database.js');
|
|---|
| 11 | const db = dbMod.default;
|
|---|
| 12 | dbMod.initializeDatabase();
|
|---|
| 13 | const PP = (await import('../src/services/PaidPatreonService.js')).default;
|
|---|
| 14 | const { encrypt, decrypt, signBlob, verifyBlob } = await import('../src/services/CryptoBox.js');
|
|---|
| 15 |
|
|---|
| 16 | test('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 |
|
|---|
| 24 | test('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 |
|
|---|
| 32 | test('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 |
|
|---|
| 52 | test('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 |
|
|---|
| 60 | test('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 |
|
|---|
| 67 | test('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 |
|
|---|
| 85 | test('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 | });
|
|---|