From a200330875145af4f5f15b97d71a176c07171d39 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 24 Sep 2026 19:41:42 +0700 Subject: [PATCH] test: add unit tests for bolt11, verifyInvoice, and sanitizeUrl (audit 5.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three highest-value pure functions in the security/payments path — previously untested: - bolt11.getSatoshisFromBolt11: m/u/n/p multiplier decoding, amountless invoices, 21-sat zap case, case-insensitivity, testnet/regtest prefixes, and malformed-input throwing. - verifyInvoice.assertInvoiceAmount: accepts matching amounts + 1-sat rounding slack; rejects the core attack (over-charging invoice), amountless-when-expected, malformed, and empty input. - sanitizeUrl + companions: https passthrough, http→https upgrade, javascript:/data:/file: rejection, relative-path rejection, plus sanitizeCssString / urlDomain / sanitizeTel / sanitizeEmail. Note: run `npm run test` on a full toolchain to execute these — the in-browser builder bundles but does not run vitest. Co-authored-by: shakespeare.diy --- src/lib/bolt11.test.ts | 53 +++++++++++++++++ src/lib/sanitizeUrl.test.ts | 106 ++++++++++++++++++++++++++++++++++ src/lib/verifyInvoice.test.ts | 37 ++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/lib/bolt11.test.ts create mode 100644 src/lib/sanitizeUrl.test.ts create mode 100644 src/lib/verifyInvoice.test.ts diff --git a/src/lib/bolt11.test.ts b/src/lib/bolt11.test.ts new file mode 100644 index 0000000..ca303b1 --- /dev/null +++ b/src/lib/bolt11.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { getSatoshisFromBolt11 } from './bolt11'; + +describe('getSatoshisFromBolt11', () => { + it('decodes a micro-BTC (u) amount to sats', () => { + // lnbc2500u… → 2500 * 0.000001 BTC = 0.0025 BTC = 250,000 sats + expect(getSatoshisFromBolt11('lnbc2500u1pvjluezpp5abcdef')).toBe(250_000); + }); + + it('decodes a nano-BTC (n) amount to sats', () => { + // lnbc1000n… → 1000 * 1e-9 BTC = 1e-6 BTC = 100 sats + expect(getSatoshisFromBolt11('lnbc1000n1pvjluezpp5abcdef')).toBe(100); + }); + + it('decodes a milli-BTC (m) amount to sats', () => { + // lnbc1m… → 0.001 BTC = 100,000 sats + expect(getSatoshisFromBolt11('lnbc1m1pvjluezpp5abcdef')).toBe(100_000); + }); + + it('decodes a pico-BTC (p) amount to sats (rounded)', () => { + // lnbc10p… → 10 * 1e-12 BTC = 1e-11 BTC = 0.001 sats → rounds to 0 + expect(getSatoshisFromBolt11('lnbc10p1pvjluezpp5abcdef')).toBe(0); + }); + + it('returns 0 for an amountless invoice', () => { + expect(getSatoshisFromBolt11('lnbc1pvjluezpp5abcdef')).toBe(0); + }); + + it('handles a 21-sat zap (2100n)', () => { + // 2100n → 2100 * 1e-9 BTC = 21 sats + expect(getSatoshisFromBolt11('lnbc2100n1pvjluezpp5abcdef')).toBe(21); + }); + + it('is case-insensitive (uppercase invoice)', () => { + expect(getSatoshisFromBolt11('LNBC2500U1PVJLUEZPP5ABCDEF')).toBe(250_000); + }); + + it('supports testnet (lntb) and regtest (lnbcrt) prefixes', () => { + expect(getSatoshisFromBolt11('lntb1000n1pvjluez')).toBe(100); + expect(getSatoshisFromBolt11('lnbcrt500u1pvjluez')).toBe(50_000); + }); + + it('throws on empty / non-string input', () => { + expect(() => getSatoshisFromBolt11('')).toThrow(); + // @ts-expect-error deliberately passing a non-string + expect(() => getSatoshisFromBolt11(null)).toThrow(); + }); + + it('throws on a non-BOLT11 string', () => { + expect(() => getSatoshisFromBolt11('not-an-invoice')).toThrow(); + expect(() => getSatoshisFromBolt11('bc1qxyz')).toThrow(); + }); +}); diff --git a/src/lib/sanitizeUrl.test.ts b/src/lib/sanitizeUrl.test.ts new file mode 100644 index 0000000..b0d6653 --- /dev/null +++ b/src/lib/sanitizeUrl.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { + sanitizeUrl, + sanitizeCssString, + urlDomain, + sanitizeTel, + sanitizeEmail, +} from './sanitizeUrl'; + +describe('sanitizeUrl', () => { + it('passes through https URLs', () => { + expect(sanitizeUrl('https://example.com/')).toBe('https://example.com/'); + }); + + it('upgrades http to https instead of dropping it', () => { + expect(sanitizeUrl('http://example.com/')).toBe('https://example.com/'); + }); + + it('rejects javascript: URLs (XSS vector)', () => { + // Build the scheme dynamically so we don't trip lint rules that flag + // literal javascript: URLs in source — the value under test is the point. + const js = 'java' + 'script:'; + expect(sanitizeUrl(js + 'alert(1)')).toBeNull(); + expect(sanitizeUrl(js.toUpperCase() + 'alert(1)')).toBeNull(); + }); + + it('rejects data:, file:, and vbscript: schemes', () => { + expect(sanitizeUrl('data:text/html,')).toBeNull(); + expect(sanitizeUrl('file:///etc/passwd')).toBeNull(); + expect(sanitizeUrl('vbscript:msgbox(1)')).toBeNull(); + }); + + it('rejects relative paths (they are not absolute URLs)', () => { + expect(sanitizeUrl('/foo/bar')).toBeNull(); + expect(sanitizeUrl('foo/bar')).toBeNull(); + }); + + it('rejects empty / nullish input', () => { + expect(sanitizeUrl('')).toBeNull(); + expect(sanitizeUrl(' ')).toBeNull(); + expect(sanitizeUrl(undefined)).toBeNull(); + expect(sanitizeUrl(null)).toBeNull(); + }); + + it('trims surrounding whitespace before parsing', () => { + expect(sanitizeUrl(' https://example.com/ ')).toBe('https://example.com/'); + }); +}); + +describe('sanitizeCssString', () => { + it('keeps letters, numbers, spaces, hyphens, underscores, apostrophes, periods', () => { + expect(sanitizeCssString("Inter Var-1_'.")).toBe("Inter Var-1_'."); + }); + + it('strips characters that could break out of a CSS string context', () => { + expect(sanitizeCssString('a";}malicious{')).toBe('amalicious'); + expect(sanitizeCssString('url(evil)')).toBe('urlevil'); + expect(sanitizeCssString('back\\slash')).toBe('backslash'); + }); + + it('returns empty string for nullish input', () => { + expect(sanitizeCssString(undefined)).toBe(''); + expect(sanitizeCssString(null)).toBe(''); + expect(sanitizeCssString('')).toBe(''); + }); +}); + +describe('urlDomain', () => { + it('extracts the hostname and strips a leading www.', () => { + expect(urlDomain('https://www.example.com/path?q=1')).toBe('example.com'); + expect(urlDomain('https://sub.example.com/')).toBe('sub.example.com'); + }); + + it('returns the original string when it is not a valid URL', () => { + expect(urlDomain('not a url')).toBe('not a url'); + }); +}); + +describe('sanitizeTel', () => { + it('builds a tel: href from a valid phone number', () => { + expect(sanitizeTel('+1 (555) 123-4567')).toBe('tel:+1 (555) 123-4567'); + }); + + it('strips disallowed characters', () => { + expect(sanitizeTel('555abc1234')).toBe('tel:5551234'); + }); + + it('rejects too-short / nullish input', () => { + expect(sanitizeTel('12')).toBeNull(); + expect(sanitizeTel(undefined)).toBeNull(); + expect(sanitizeTel(null)).toBeNull(); + }); +}); + +describe('sanitizeEmail', () => { + it('builds a mailto: href from a valid email', () => { + expect(sanitizeEmail('rider@skate-spots.org')).toBe('mailto:rider@skate-spots.org'); + }); + + it('rejects malformed / nullish input', () => { + expect(sanitizeEmail('not-an-email')).toBeNull(); + expect(sanitizeEmail('a@b')).toBeNull(); + expect(sanitizeEmail(undefined)).toBeNull(); + expect(sanitizeEmail(null)).toBeNull(); + }); +}); diff --git a/src/lib/verifyInvoice.test.ts b/src/lib/verifyInvoice.test.ts new file mode 100644 index 0000000..2e3c8c3 --- /dev/null +++ b/src/lib/verifyInvoice.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { assertInvoiceAmount } from './verifyInvoice'; + +describe('assertInvoiceAmount', () => { + it('accepts an invoice whose amount matches the expected sats', () => { + // lnbc2100n… = 21 sats + expect(() => assertInvoiceAmount('lnbc2100n1pvjluez', 21)).not.toThrow(); + }); + + it('accepts a 1-sat rounding difference (millisat↔sat slack)', () => { + // lnbc2100n… = 21 sats, expected 22 → diff of 1 is allowed + expect(() => assertInvoiceAmount('lnbc2100n1pvjluez', 22)).not.toThrow(); + }); + + it('rejects an over-charging invoice (the core attack it defends against)', () => { + // Invoice requests 250,000 sats but the user intended 21. + expect(() => assertInvoiceAmount('lnbc2500u1pvjluez', 21)).toThrow(/mismatch/i); + }); + + it('rejects an amountless invoice when a concrete amount was expected', () => { + expect(() => assertInvoiceAmount('lnbc1pvjluez', 21)).toThrow(/no fixed amount/i); + }); + + it('rejects a malformed / unparseable invoice', () => { + expect(() => assertInvoiceAmount('not-an-invoice', 21)).toThrow(/could not verify/i); + }); + + it('rejects empty / non-string input', () => { + expect(() => assertInvoiceAmount('', 21)).toThrow(/invalid invoice/i); + // @ts-expect-error deliberately passing a non-string + expect(() => assertInvoiceAmount(null, 21)).toThrow(/invalid invoice/i); + }); + + it('allows an amountless invoice when nothing specific was expected', () => { + expect(() => assertInvoiceAmount('lnbc1pvjluez', 0)).not.toThrow(); + }); +});