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:
parent
01d35823cc
commit
a200330875
3 changed files with 196 additions and 0 deletions
53
src/lib/bolt11.test.ts
Normal file
53
src/lib/bolt11.test.ts
Normal 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
106
src/lib/sanitizeUrl.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/lib/verifyInvoice.test.ts
Normal file
37
src/lib/verifyInvoice.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue