| 1 | // Paid posts slice 4 (klonkt-demo-3lz): the per-post unlock. The WebAuthn
|
|---|
| 2 | // assertion needs a browser, so here we cover the pure pieces: authentication
|
|---|
| 3 | // options carry a challenge and empty allowCredentials (discoverable), the
|
|---|
| 4 | // counter bumps, and the tier gate compares entitlement cents to the post's.
|
|---|
| 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 Passkey = (await import('../src/services/PasskeyService.js')).default;
|
|---|
| 16 |
|
|---|
| 17 | test('authentication options carry a challenge, rpID host, and empty allowCredentials', async () => {
|
|---|
| 18 | const opts = await Passkey.authenticationOptions('https://test.example');
|
|---|
| 19 | assert.ok(opts.challenge && typeof opts.challenge === 'string');
|
|---|
| 20 | assert.equal(opts.rpId, 'test.example');
|
|---|
| 21 | assert.deepEqual(opts.allowCredentials || [], []); // discoverable: browser offers the passkeys
|
|---|
| 22 | });
|
|---|
| 23 |
|
|---|
| 24 | test('bumpCounter persists the new signature counter (clone detection)', () => {
|
|---|
| 25 | Passkey.storeEntitlement({ credentialId: 'uc1', siteId: 's1', publicKey: 'PK', counter: 4, minCents: 300 });
|
|---|
| 26 | Passkey.bumpCounter('uc1', 7);
|
|---|
| 27 | const row = db.prepare('SELECT counter FROM paid_entitlements WHERE credential_id = ?').get('uc1');
|
|---|
| 28 | assert.equal(row.counter, 7);
|
|---|
| 29 | });
|
|---|
| 30 |
|
|---|
| 31 | test('tier gate: an entitlement below the post cents is refused, at/above passes', () => {
|
|---|
| 32 | Passkey.storeEntitlement({ credentialId: 'uc2', siteId: 's1', publicKey: 'PK', counter: 0, minCents: 300 });
|
|---|
| 33 | const ent = Passkey.getEntitlement('uc2', 's1');
|
|---|
| 34 | // mirrors the /paid/unlock check: (ent.min_cents || 0) < payload.cents -> refuse
|
|---|
| 35 | assert.equal((ent.min_cents || 0) < 500, true); // post needs 500, entitlement 300 -> blocked
|
|---|
| 36 | assert.equal((ent.min_cents || 0) < 300, false); // post needs 300 -> allowed
|
|---|
| 37 | assert.equal((ent.min_cents || 0) < 100, false); // post needs 100 -> allowed
|
|---|
| 38 | });
|
|---|
| 39 |
|
|---|
| 40 | test('an expired entitlement is not returned to the unlock path', () => {
|
|---|
| 41 | Passkey.storeEntitlement({ credentialId: 'uc3', siteId: 's1', publicKey: 'PK', minCents: 100, ttlDays: 30 });
|
|---|
| 42 | db.prepare('UPDATE paid_entitlements SET expires_at = 1 WHERE credential_id = ?').run('uc3');
|
|---|
| 43 | assert.equal(Passkey.getEntitlement('uc3', 's1'), null);
|
|---|
| 44 | });
|
|---|