test: add unit tests for bolt11, verifyInvoice, and sanitizeUrl (audit 5.5)

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-24 19:41:42 +07:00
parent 01d35823cc
commit a200330875
3 changed files with 196 additions and 0 deletions

53
src/lib/bolt11.test.ts Normal file
View file

@ -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();
});
});

106
src/lib/sanitizeUrl.test.ts Normal file
View file

@ -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,<script>alert(1)</script>')).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();
});
});

View file

@ -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();
});
});