| 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.
|
|---|
| 5 | import { test } from 'node:test';
|
|---|
| 6 | import assert from 'node:assert/strict';
|
|---|
| 7 |
|
|---|
| 8 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 9 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 10 | process.env.PAID_SECRET = 'a-test-paid-secret-of-sufficient-length';
|
|---|
| 11 |
|
|---|
| 12 | const dbMod = await import('../src/config/database.js');
|
|---|
| 13 | const db = dbMod.default;
|
|---|
| 14 | dbMod.initializeDatabase();
|
|---|
| 15 | const PP = (await import('../src/services/PaidPatreonService.js')).default;
|
|---|
| 16 | const Passkey = (await import('../src/services/PasskeyService.js')).default;
|
|---|
| 17 |
|
|---|
| 18 | PP.saveOwnerConfig('s1', { clientId: 'cid', clientSecret: 'sec', campaignId: '42', defaultMinCents: 300 });
|
|---|
| 19 |
|
|---|
| 20 | const 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 |
|
|---|
| 29 | test('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 |
|
|---|
| 37 | test('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 |
|
|---|
| 47 | test('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 |
|
|---|
| 57 | test('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 |
|
|---|
| 71 | test('pickCampaignMembership: no memberships → null', async () => {
|
|---|
| 72 | const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
|
|---|
| 73 | assert.equal(pickCampaignMembership({ data: {}, included: [] }, '42'), null);
|
|---|
| 74 | });
|
|---|
| 75 |
|
|---|
| 76 | test('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 |
|
|---|
| 90 | test('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 |
|
|---|
| 97 | test('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 |
|
|---|
| 113 | test('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 |
|
|---|
| 124 | test('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 | });
|
|---|