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

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

Fix: derive the owner's campaign id from the creator token (strict match)

The tester's diagnose line was the smoking gun: config campaign=29148518,
but his 12 memberships (Patreon /identity returns ALL memberships across
every creator, not just this one) contained no 29148518. His real, active
pledges were to 1373144 and 16300989. So the admin campaign_id was simply
wrong, and a wrong id locks out real patrons.

Two corrections:

  • Drop the sole-membership fallback added earlier. It was based on the wrong assumption that /identity is creator-scoped; with global memberships it would grant access to someone backing a DIFFERENT creator. pickCampaign- Membership is strict again: exact campaign match or nothing.
  • Auto-derive the authoritative campaign id from the creator token (GET /campaigns returns the owner's own campaign), prefer it over the typed value, and self-heal the stored config when they differ. The owner no longer has to find/enter the campaign id by hand.

The diagnose line now shows owner=<from token> config=<typed> so a mismatch
is obvious.

Changed files:
src/services/PaidPatreonService.js

  • pickCampaignMembership strict; fetchOwnerCampaignId (creator token); verifyPatron derives + self-heals the campaign id, richer diag

test/paid-patron.test.js

  • strict match tests; fetchOwnerCampaignId with mock fetch

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

  • Property mode set to 100644
File size: 6.8 KB
Line 
1// Paid posts slice 3 (klonkt-demo-aki): patron verification + pseudonymous
2// passkey entitlements. The WebAuthn ceremony itself needs a browser, so here
3// we cover the pure parsing, the Patreon exchange (mock fetch), the entitlement
4// store, and that registration options carry a challenge.
5import { test } from 'node:test';
6import assert from 'node:assert/strict';
7
8process.env.DATABASE_PATH = ':memory:';
9process.env.PUBLIC_BASE_URL = 'https://test.example';
10process.env.PAID_SECRET = 'a-test-paid-secret-of-sufficient-length';
11
12const dbMod = await import('../src/config/database.js');
13const db = dbMod.default;
14dbMod.initializeDatabase();
15const PP = (await import('../src/services/PaidPatreonService.js')).default;
16const Passkey = (await import('../src/services/PasskeyService.js')).default;
17
18PP.saveOwnerConfig('s1', { clientId: 'cid', clientSecret: 'sec', campaignId: '42', defaultMinCents: 300 });
19
20const identityWith = (campaignId, status, cents) => ({
21 data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }] } } },
22 included: [
23 { type: 'member', id: 'm1', attributes: { patron_status: status, currently_entitled_amount_cents: cents },
24 relationships: { campaign: { data: { type: 'campaign', id: campaignId } } } },
25 { type: 'campaign', id: campaignId },
26 ],
27});
28
29test('pickCampaignMembership: exact campaign match wins', async () => {
30 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
31 const id = identityWith('42', 'active_patron', 500);
32 const m = pickCampaignMembership(id, '42');
33 assert.equal(m.status, 'active_patron');
34 assert.equal(m.cents, 500);
35});
36
37test('pickCampaignMembership: STRICT — a non-matching campaign_id is refused', async () => {
38 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
39 // /identity returns ALL the visitor's memberships across every creator, so a
40 // membership to a DIFFERENT campaign must never grant access here.
41 const id = identityWith('42', 'active_patron', 500);
42 assert.equal(pickCampaignMembership(id, '999'), null); // different campaign → no
43 assert.equal(pickCampaignMembership(id, ''), null); // no campaign → null
44 assert.equal(pickCampaignMembership(id, '42').cents, 500); // exact → yes
45});
46
47test('fetchOwnerCampaignId reads the creator campaign from the creator token', async () => {
48 PP.saveOwnerConfig('s3', { clientId: 'c', clientSecret: 's', campaignId: 'WRONG', accessToken: 'creator-tok', refreshToken: 'r', tokenExp: Math.floor(Date.now() / 1000) + 99999 });
49 const fetchMock = async (url) => {
50 if (url.includes('/campaigns')) return { ok: true, json: async () => ({ data: [{ type: 'campaign', id: '16300989' }] }) };
51 return { ok: false, status: 404, json: async () => ({}) };
52 };
53 const id = await PP.fetchOwnerCampaignId('s3', fetchMock);
54 assert.equal(id, '16300989');
55});
56
57test('pickCampaignMembership: many memberships, only the exact campaign matches', async () => {
58 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
59 const id = {
60 data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }, { type: 'member', id: 'm2' }] } } },
61 included: [
62 { type: 'member', id: 'm1', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 300 }, relationships: { campaign: { data: { type: 'campaign', id: '42' } } } },
63 { type: 'member', id: 'm2', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 800 }, relationships: { campaign: { data: { type: 'campaign', id: '77' } } } },
64 ],
65 };
66 assert.equal(pickCampaignMembership(id, '999'), null); // none of them
67 assert.equal(pickCampaignMembership(id, '42').cents, 300);
68 assert.equal(pickCampaignMembership(id, '77').cents, 800);
69});
70
71test('pickCampaignMembership: no memberships → null', async () => {
72 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
73 assert.equal(pickCampaignMembership({ data: {}, included: [] }, '42'), null);
74});
75
76test('verifyPatron exchanges the code and reads the membership (mock fetch)', async () => {
77 const calls = [];
78 const fetchMock = async (url, opts) => {
79 calls.push(url);
80 if (url.includes('/token')) return { ok: true, json: async () => ({ access_token: 'patron-tok' }) };
81 return { ok: true, json: async () => identityWith('42', 'active_patron', 800) };
82 };
83 const m = await PP.verifyPatron('s1', 'the-code', 'https://test.example/paid/callback', fetchMock);
84 assert.equal(m.status, 'active_patron');
85 assert.equal(m.cents, 800);
86 assert.ok(calls[0].includes('patreon.com'));
87 assert.ok(calls[1].includes('identity'));
88});
89
90test('registration options carry a challenge and the site host as rpID', async () => {
91 const opts = await Passkey.registrationOptions('https://test.example', 's1');
92 assert.ok(opts.challenge && typeof opts.challenge === 'string');
93 assert.equal(opts.rp.id, 'test.example');
94 assert.equal(opts.authenticatorSelection.residentKey, 'required');
95});
96
97test('entitlement stores, reads, expires, prunes; no patron identity present', () => {
98 Passkey.storeEntitlement({ credentialId: 'cred1', siteId: 's1', publicKey: 'PUBKEY', counter: 0, minCents: 500, ttlDays: 30 });
99 const e = Passkey.getEntitlement('cred1', 's1');
100 assert.ok(e);
101 assert.equal(e.min_cents, 500);
102 // the row has no name/email/patreon id
103 const cols = Object.keys(e);
104 assert.ok(!cols.some((c) => /name|email|patron|user/i.test(c)), 'no identity columns');
105 // expired entitlement is not returned and gets pruned
106 Passkey.storeEntitlement({ credentialId: 'cred2', siteId: 's1', publicKey: 'PK', minCents: 100, ttlDays: 30 });
107 db.prepare('UPDATE paid_entitlements SET expires_at = 1 WHERE credential_id = ?').run('cred2');
108 assert.equal(Passkey.getEntitlement('cred2', 's1'), null);
109 assert.equal(Passkey.pruneExpired() >= 1, true);
110 assert.ok(Passkey.getEntitlement('cred1', 's1')); // the fresh one survives
111});
112
113test('patreonUrl: set, kept on unrelated save, cleared on empty', () => {
114 PP.saveOwnerConfig('s2', { clientId: 'c', clientSecret: 's', campaignId: '7', patreonUrl: 'https://patreon.com/x' });
115 assert.equal(PP.patreonUrl('s2'), 'https://patreon.com/x');
116 // a save that does NOT mention patreonUrl (e.g. token refresh) keeps it
117 PP.saveOwnerConfig('s2', { defaultMinCents: 200 });
118 assert.equal(PP.patreonUrl('s2'), 'https://patreon.com/x');
119 // an explicit empty value clears it
120 PP.saveOwnerConfig('s2', { patreonUrl: '' });
121 assert.equal(PP.patreonUrl('s2'), null);
122});
123
124test('deleteEntitlement removes the row (forget-passkey path)', () => {
125 Passkey.storeEntitlement({ credentialId: 'cred3', siteId: 's1', publicKey: 'PK', minCents: 100 });
126 assert.equal(Passkey.deleteEntitlement('cred3'), true);
127 assert.equal(Passkey.getEntitlement('cred3', 's1'), null);
128});
Note: See TracBrowser for help on using the repository browser.