|
| 1 | +/** |
| 2 | + * Tests for the NIP-07 provider surface (nostr-provider.js). |
| 3 | + * |
| 4 | + * The provider must advertise ONLY what it implements, so that page-side |
| 5 | + * feature-detection is truthful: |
| 6 | + * - exposes getPublicKey, signEvent, nip44.{encrypt,decrypt} |
| 7 | + * - does NOT expose nip04 (deprecated, unauthenticated; not shipped) |
| 8 | + * - does NOT expose getRelays (Podkey holds no relay list) |
| 9 | + * |
| 10 | + * nostr-provider.js is a page-context IIFE that assigns window.nostr, so the |
| 11 | + * test mounts a minimal window (EventTarget-backed) and imports the module to |
| 12 | + * run it, then inspects the installed surface and its request wiring. |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, before } from 'node:test'; |
| 16 | +import assert from 'node:assert/strict'; |
| 17 | + |
| 18 | +let nostr; |
| 19 | +const events = new EventTarget(); |
| 20 | + |
| 21 | +before(async () => { |
| 22 | + global.window = { |
| 23 | + addEventListener: (...a) => events.addEventListener(...a), |
| 24 | + removeEventListener: (...a) => events.removeEventListener(...a), |
| 25 | + dispatchEvent: (e) => events.dispatchEvent(e) |
| 26 | + }; |
| 27 | + // Running the IIFE installs window.nostr. |
| 28 | + await import('../src/nostr-provider.js'); |
| 29 | + nostr = global.window.nostr; |
| 30 | +}); |
| 31 | + |
| 32 | +describe('provider surface (honest feature-detection)', () => { |
| 33 | + it('exposes exactly getPublicKey, signEvent and nip44', () => { |
| 34 | + assert.deepEqual(Object.keys(nostr).sort(), ['getPublicKey', 'nip44', 'signEvent']); |
| 35 | + }); |
| 36 | + |
| 37 | + it('exposes nip44.encrypt and nip44.decrypt as functions', () => { |
| 38 | + assert.equal(typeof nostr.nip44.encrypt, 'function'); |
| 39 | + assert.equal(typeof nostr.nip44.decrypt, 'function'); |
| 40 | + }); |
| 41 | + |
| 42 | + it('does NOT expose nip04 (deprecated scheme is not shipped)', () => { |
| 43 | + assert.equal('nip04' in nostr, false); |
| 44 | + assert.equal(nostr.nip04, undefined); |
| 45 | + }); |
| 46 | + |
| 47 | + it('does NOT expose getRelays (no relay list is held)', () => { |
| 48 | + assert.equal('getRelays' in nostr, false); |
| 49 | + assert.equal(nostr.getRelays, undefined); |
| 50 | + }); |
| 51 | + |
| 52 | + it('getPublicKey and signEvent are async functions', () => { |
| 53 | + assert.equal(typeof nostr.getPublicKey, 'function'); |
| 54 | + assert.equal(typeof nostr.signEvent, 'function'); |
| 55 | + }); |
| 56 | +}); |
| 57 | + |
| 58 | +describe('signEvent input validation (before any signing)', () => { |
| 59 | + // These reject synchronously inside the provider, never reaching the wire, |
| 60 | + // so no response listener is needed. |
| 61 | + it('rejects a non-object event', async () => { |
| 62 | + await assert.rejects(() => nostr.signEvent(null), /Event must be an object/); |
| 63 | + await assert.rejects(() => nostr.signEvent('nope'), /Event must be an object/); |
| 64 | + }); |
| 65 | + |
| 66 | + it('rejects a missing/typed kind', async () => { |
| 67 | + await assert.rejects(() => nostr.signEvent({ created_at: 1, tags: [], content: '' }), /kind must be a number/); |
| 68 | + }); |
| 69 | + |
| 70 | + it('rejects a missing created_at', async () => { |
| 71 | + await assert.rejects(() => nostr.signEvent({ kind: 1, tags: [], content: '' }), /created_at must be a number/); |
| 72 | + }); |
| 73 | + |
| 74 | + it('rejects non-array tags', async () => { |
| 75 | + await assert.rejects(() => nostr.signEvent({ kind: 1, created_at: 1, tags: 'x', content: '' }), /tags must be an array/); |
| 76 | + }); |
| 77 | + |
| 78 | + it('rejects non-string content', async () => { |
| 79 | + await assert.rejects(() => nostr.signEvent({ kind: 1, created_at: 1, tags: [], content: 5 }), /content must be a string/); |
| 80 | + }); |
| 81 | +}); |
| 82 | + |
| 83 | +describe('provider request wiring (podkey-request CustomEvent)', () => { |
| 84 | + /** |
| 85 | + * Capture the next podkey-request the provider emits, reply to it with the |
| 86 | + * matching id, and return the request detail for assertions. |
| 87 | + */ |
| 88 | + function captureRequest (reply) { |
| 89 | + return new Promise((resolve) => { |
| 90 | + const handler = (e) => { |
| 91 | + global.window.removeEventListener('podkey-request', handler); |
| 92 | + const detail = e.detail; |
| 93 | + // Respond on the next tick so the provider's listener is registered. |
| 94 | + queueMicrotask(() => { |
| 95 | + global.window.dispatchEvent(new CustomEvent('podkey-response', { |
| 96 | + detail: { id: detail.id, result: reply(detail) } |
| 97 | + })); |
| 98 | + }); |
| 99 | + resolve(detail); |
| 100 | + }; |
| 101 | + global.window.addEventListener('podkey-request', handler); |
| 102 | + }); |
| 103 | + } |
| 104 | + |
| 105 | + it('getPublicKey dispatches a GET_PUBLIC_KEY request and resolves the response', async () => { |
| 106 | + const reqP = captureRequest(() => 'deadbeef'.repeat(8)); |
| 107 | + const resP = nostr.getPublicKey(); |
| 108 | + const req = await reqP; |
| 109 | + assert.equal(req.type, 'GET_PUBLIC_KEY'); |
| 110 | + assert.ok(req.id, 'request must carry a correlation id'); |
| 111 | + assert.equal(await resP, 'deadbeef'.repeat(8)); |
| 112 | + }); |
| 113 | + |
| 114 | + it('signEvent forwards the validated event under SIGN_EVENT', async () => { |
| 115 | + const event = { kind: 1, created_at: 1700000000, tags: [['t', 'x']], content: 'gm' }; |
| 116 | + const reqP = captureRequest((d) => ({ ...d.event, id: 'a'.repeat(64), pubkey: 'b'.repeat(64), sig: 'c'.repeat(128) })); |
| 117 | + const resP = nostr.signEvent(event); |
| 118 | + const req = await reqP; |
| 119 | + assert.equal(req.type, 'SIGN_EVENT'); |
| 120 | + assert.deepEqual(req.event, event); |
| 121 | + const signed = await resP; |
| 122 | + assert.equal(signed.sig.length, 128); |
| 123 | + }); |
| 124 | + |
| 125 | + it('nip44.encrypt dispatches NIP44_ENCRYPT with pubkey + plaintext (field is "pubkey")', async () => { |
| 126 | + const reqP = captureRequest(() => 'BASE64PAYLOAD'); |
| 127 | + const resP = nostr.nip44.encrypt('f'.repeat(64), 'secret'); |
| 128 | + const req = await reqP; |
| 129 | + assert.equal(req.type, 'NIP44_ENCRYPT'); |
| 130 | + assert.equal(req.pubkey, 'f'.repeat(64)); |
| 131 | + assert.equal(req.plaintext, 'secret'); |
| 132 | + assert.equal('peer' in req, false, 'the reconciled field name is "pubkey", not "peer"'); |
| 133 | + assert.equal(await resP, 'BASE64PAYLOAD'); |
| 134 | + }); |
| 135 | + |
| 136 | + it('nip44.decrypt dispatches NIP44_DECRYPT with pubkey + ciphertext', async () => { |
| 137 | + const reqP = captureRequest(() => 'plaintext-out'); |
| 138 | + const resP = nostr.nip44.decrypt('f'.repeat(64), 'BASE64PAYLOAD'); |
| 139 | + const req = await reqP; |
| 140 | + assert.equal(req.type, 'NIP44_DECRYPT'); |
| 141 | + assert.equal(req.pubkey, 'f'.repeat(64)); |
| 142 | + assert.equal(req.ciphertext, 'BASE64PAYLOAD'); |
| 143 | + assert.equal(await resP, 'plaintext-out'); |
| 144 | + }); |
| 145 | + |
| 146 | + it('propagates a background error back to the caller as a rejection', async () => { |
| 147 | + const handler = (e) => { |
| 148 | + global.window.removeEventListener('podkey-request', handler); |
| 149 | + queueMicrotask(() => { |
| 150 | + global.window.dispatchEvent(new CustomEvent('podkey-response', { |
| 151 | + detail: { id: e.detail.id, error: 'User denied permission' } |
| 152 | + })); |
| 153 | + }); |
| 154 | + }; |
| 155 | + global.window.addEventListener('podkey-request', handler); |
| 156 | + await assert.rejects(() => nostr.getPublicKey(), /User denied permission/); |
| 157 | + }); |
| 158 | +}); |
0 commit comments