|
| 1 | +/** |
| 2 | + * Tests for bodyToHashHex — the page-side NIP-98 `payload` body hashing in |
| 3 | + * nip98-interceptor.js. The raw request body never crosses to the background |
| 4 | + * service worker; the page computes its SHA-256 here (the only context where |
| 5 | + * FormData / URLSearchParams / Blob survive intact) and ships only the digest. |
| 6 | + * |
| 7 | + * bodyToHashHex is a private function inside the interceptor's page-context |
| 8 | + * IIFE (no ESM export — classic scripts can't import). To exercise the real |
| 9 | + * source it is loaded and run inside a vm sandbox with a minimal page (window, |
| 10 | + * fetch, XHR), and the bodyHash the interceptor emits on the |
| 11 | + * podkey-nip98-request event is captured. This drives the genuine code path |
| 12 | + * (window.fetch wrapper -> getNip98AuthHeader -> bodyToHashHex) rather than a |
| 13 | + * reimplementation. |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, before } from 'node:test'; |
| 17 | +import assert from 'node:assert/strict'; |
| 18 | +import { readFileSync } from 'node:fs'; |
| 19 | +import { createHash, webcrypto } from 'node:crypto'; |
| 20 | +import vm from 'node:vm'; |
| 21 | + |
| 22 | +const sha256hex = (input) => createHash('sha256').update(input).digest('hex'); |
| 23 | + |
| 24 | +/** Build a fresh sandboxed page with the interceptor installed. */ |
| 25 | +function makeInterceptedPage () { |
| 26 | + const src = readFileSync(new URL('../src/nip98-interceptor.js', import.meta.url), 'utf8'); |
| 27 | + const bus = new EventTarget(); |
| 28 | + const win = { |
| 29 | + __podkey_nip98_intercepted: false, |
| 30 | + addEventListener: (...a) => bus.addEventListener(...a), |
| 31 | + removeEventListener: (...a) => bus.removeEventListener(...a), |
| 32 | + dispatchEvent: (e) => bus.dispatchEvent(e), |
| 33 | + // originalFetch: a stub the wrapper calls after deciding on auth. |
| 34 | + fetch: async () => ({ status: 200, redirected: false, url: '' }) |
| 35 | + }; |
| 36 | + |
| 37 | + // Echo every nip98 request back immediately so the fetch wrapper resolves, |
| 38 | + // recording the detail (which carries bodyHash) for assertions. |
| 39 | + const requests = []; |
| 40 | + bus.addEventListener('podkey-nip98-request', (e) => { |
| 41 | + requests.push(e.detail); |
| 42 | + bus.dispatchEvent(new CustomEvent('podkey-nip98-response', { detail: { id: e.detail.id, result: null } })); |
| 43 | + }); |
| 44 | + |
| 45 | + // Minimal XHR so the interceptor can patch its prototype without throwing. |
| 46 | + function XHR () {} |
| 47 | + XHR.prototype = { open () {}, send () {}, setRequestHeader () {} }; |
| 48 | + |
| 49 | + const sandbox = { |
| 50 | + window: win, |
| 51 | + XMLHttpRequest: XHR, |
| 52 | + crypto: webcrypto, |
| 53 | + CustomEvent, Headers, Request, TextEncoder, |
| 54 | + Blob, URLSearchParams, ArrayBuffer, Uint8Array, Uint16Array, Array, Object, |
| 55 | + console, setTimeout, clearTimeout |
| 56 | + }; |
| 57 | + vm.createContext(sandbox); |
| 58 | + vm.runInContext(src, sandbox); |
| 59 | + |
| 60 | + return { |
| 61 | + /** Issue a fetch through the wrapped fetch and return the captured bodyHash. */ |
| 62 | + async fetchAndGetHash (init) { |
| 63 | + requests.length = 0; |
| 64 | + await win.fetch('https://pod.test/r', init); |
| 65 | + assert.equal(requests.length, 1, 'interceptor should emit exactly one nip98 request'); |
| 66 | + return requests[0].bodyHash; |
| 67 | + }, |
| 68 | + /** Issue a fetch and return whether any nip98 request was emitted. */ |
| 69 | + async fetchEmitsRequest (input, init) { |
| 70 | + requests.length = 0; |
| 71 | + await win.fetch(input, init); |
| 72 | + return requests.length > 0; |
| 73 | + } |
| 74 | + }; |
| 75 | +} |
| 76 | + |
| 77 | +describe('bodyToHashHex (NIP-98 page-side body hashing)', () => { |
| 78 | + let page; |
| 79 | + before(() => { page = makeInterceptedPage(); }); |
| 80 | + |
| 81 | + it('hashes a string body to its sha-256 hex', async () => { |
| 82 | + const body = '{"hello":"world"}'; |
| 83 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body }), sha256hex(body)); |
| 84 | + }); |
| 85 | + |
| 86 | + it('hashes a URLSearchParams body via its serialized form', async () => { |
| 87 | + const params = new URLSearchParams({ a: '1', b: 'two' }); |
| 88 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body: params }), sha256hex(params.toString())); |
| 89 | + }); |
| 90 | + |
| 91 | + it('hashes a Blob body by its bytes', async () => { |
| 92 | + const text = 'blob-bytes-😀'; |
| 93 | + const blob = new Blob([text]); |
| 94 | + const expected = sha256hex(Buffer.from(text, 'utf8')); |
| 95 | + assert.equal(await page.fetchAndGetHash({ method: 'PUT', body: blob }), expected); |
| 96 | + }); |
| 97 | + |
| 98 | + it('hashes an ArrayBuffer body', async () => { |
| 99 | + const bytes = new Uint8Array([1, 2, 3, 4, 250, 255]); |
| 100 | + const expected = sha256hex(Buffer.from(bytes)); |
| 101 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body: bytes.buffer }), expected); |
| 102 | + }); |
| 103 | + |
| 104 | + it('hashes a typed-array (Uint8Array) body', async () => { |
| 105 | + const bytes = new Uint8Array([10, 20, 30]); |
| 106 | + const expected = sha256hex(Buffer.from(bytes)); |
| 107 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body: bytes }), expected); |
| 108 | + }); |
| 109 | + |
| 110 | + it('respects a typed-array view offset (does not hash the whole buffer)', async () => { |
| 111 | + const full = new Uint8Array([0, 0, 1, 2, 3, 0]); |
| 112 | + const view = full.subarray(2, 5); // [1,2,3] |
| 113 | + const expected = sha256hex(Buffer.from([1, 2, 3])); |
| 114 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body: view }), expected); |
| 115 | + }); |
| 116 | + |
| 117 | + it('returns "" for no body (GET)', async () => { |
| 118 | + assert.equal(await page.fetchAndGetHash({ method: 'GET' }), ''); |
| 119 | + }); |
| 120 | + |
| 121 | + it('returns "" for an empty-string body', async () => { |
| 122 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body: '' }), ''); |
| 123 | + }); |
| 124 | + |
| 125 | + it('returns "" for a FormData body (no canonical NIP-98 multipart hash)', async () => { |
| 126 | + // Documented decision: NIP-98 defines no canonical hash for multipart bodies, |
| 127 | + // so FormData is intentionally not hashed (no payload tag is added). A |
| 128 | + // FormData instance matches none of bodyToHashHex's recognised body types |
| 129 | + // (string / URLSearchParams / Blob / ArrayBuffer / typed-array) and hits the |
| 130 | + // `return ''` fallthrough. |
| 131 | + const fd = new FormData(); |
| 132 | + fd.append('field', 'value'); |
| 133 | + assert.equal(await page.fetchAndGetHash({ method: 'POST', body: fd }), ''); |
| 134 | + }); |
| 135 | +}); |
| 136 | + |
| 137 | +describe('interceptor honors a page-set Authorization (no NIP-98 injection)', () => { |
| 138 | + let page; |
| 139 | + before(() => { page = makeInterceptedPage(); }); |
| 140 | + |
| 141 | + it('does not emit a nip98 request when init.headers already has Authorization', async () => { |
| 142 | + const emitted = await page.fetchEmitsRequest('https://pod.test/r', { |
| 143 | + method: 'GET', headers: { Authorization: 'DPoP page-token' } |
| 144 | + }); |
| 145 | + assert.equal(emitted, false, 'page-set DPoP must not be overwritten by NIP-98'); |
| 146 | + }); |
| 147 | + |
| 148 | + it('emits a nip98 request when no Authorization is present', async () => { |
| 149 | + const emitted = await page.fetchEmitsRequest('https://pod.test/r', { method: 'GET' }); |
| 150 | + assert.equal(emitted, true); |
| 151 | + }); |
| 152 | +}); |
0 commit comments