morphit/apps/web/scripts/account-number-detector-smoke.ts

206 lines
5.7 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke for accountNumberDetector.
*
* Validates that the Tier 2.1 chat-composer reminder fires
* for the message shapes that contain payment-routing
* identifiers, and DOES NOT fire on regular chat prose,
* dates, prices, ID-ish strings without enough digits, etc.
*
* The detector is INFORMATIONAL — false positives just
* trigger a dismissable reminder and false negatives let
* an account-number typo through. The smoke errs on the
* "warn more" side: it's fine if "let me check 2026-05-09"
* triggers (the user dismisses it once per session); it's
* NOT fine if "my IBAN is DE89370400440532013000" doesn't.
*/
import {
detectAccountNumbers,
hasAccountNumberShape,
type AccountNumberMatchKind
} from '../src/lib/security/accountNumberDetector';
interface Scenario {
readonly name: string;
readonly input: string;
readonly expectMatch: boolean;
readonly expectKinds?: readonly AccountNumberMatchKind[];
}
const scenarios: readonly Scenario[] = [
// ─── Should fire ─────────────────────────────────────
{
name: 'IBAN — German',
input: 'Send to DE89370400440532013000 thanks',
expectMatch: true,
expectKinds: ['iban']
},
{
name: 'IBAN — French',
input: 'IBAN: FR1420041010050500013M02606',
expectMatch: true,
expectKinds: ['iban']
},
{
name: 'IBAN lowercase',
input: 'my iban is gb82west12345698765432 ok',
expectMatch: true,
expectKinds: ['iban']
},
{
name: 'US bank account number — 10 digits',
input: 'my account is 1234567890',
expectMatch: true,
expectKinds: ['digit_run']
},
{
name: 'Card-shaped — 16 digits with spaces',
input: '4111 1111 1111 1111 expires 12/26',
expectMatch: true,
expectKinds: ['digit_run']
},
{
name: 'Card-shaped — hyphen separators',
input: '4111-1111-1111-1111',
expectMatch: true,
expectKinds: ['digit_run']
},
{
name: 'SWIFT/BIC — 8 chars',
input: 'BIC code is DEUTDEFF',
expectMatch: true,
expectKinds: ['swift_bic']
},
{
name: 'SWIFT/BIC — 11 chars',
input: 'BIC: DEUTDEFF500',
expectMatch: true,
expectKinds: ['swift_bic']
},
{
name: 'Routing + account combo',
input: 'routing 071000013 account 12345678',
expectMatch: true,
expectKinds: ['digit_run']
},
{
name: 'Mixed IBAN and BIC in one message',
input: 'IBAN DE89370400440532013000 BIC DEUTDEFFXXX',
expectMatch: true
},
// ─── Should NOT fire ─────────────────────────────────
{
name: 'plain greeting',
input: 'hello, how are you?',
expectMatch: false
},
{
name: 'price discussion',
input: 'I can do $50 USD or 0.001 BTC',
expectMatch: false
},
{
name: '8-digit date YYYYMMDD',
input: 'meeting on 20260509 ok',
expectMatch: false
},
{
name: 'short order ID',
input: 'order id 1234',
expectMatch: false
},
{
name: 'mnemonic-like words (not account)',
input: 'apple banana cherry',
expectMatch: false
},
{
name: 'hex-like blob too short for SWIFT',
input: 'ref abc123',
expectMatch: false
},
{
name: 'lowercase 8-char alphanumeric (not BIC)',
input: 'see thread1234',
expectMatch: false
},
{
name: 'crypto address shape (BTC) — out of scope here',
input: 'send to bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
// IBAN regex requires 2-letter country + 2 digits + 11+
// alphanumeric. bc1q has digits 2-3 (1 + 'q'... wait
// 'q' is a letter), so bc1qxy2k... starts bc + 1q —
// '1q' is digit+letter, not 2 digits. Should NOT match
// IBAN. And it has digits but they aren't a 9-digit
// connected run.
expectMatch: false
},
{
name: 'phone number — 10 digits, hyphenated',
input: 'call me at 555-123-4567',
// Phone numbers look like account numbers under our
// detector — that's intentional per the doc comment.
// We WARN on phone numbers, even though they're often
// legitimate; the user dismisses once per session.
expectMatch: true,
expectKinds: ['digit_run']
},
{
name: 'empty string',
input: '',
expectMatch: false
},
{
name: 'whitespace only',
input: ' \n\t ',
expectMatch: false
}
];
let passed = 0;
let failed = 0;
const failures: string[] = [];
for (const s of scenarios) {
const matches = detectAccountNumbers(s.input);
const hasShape = hasAccountNumberShape(s.input);
const ok =
hasShape === s.expectMatch &&
matches.length > 0 === s.expectMatch &&
(s.expectKinds === undefined ||
s.expectKinds.every((k) => matches.some((m) => m.kind === k)));
if (ok) {
passed++;
} else {
failed++;
failures.push(
`${s.name}\n` +
` input: ${JSON.stringify(s.input)}\n` +
` expect match=${s.expectMatch}` +
(s.expectKinds ? ` kinds=[${s.expectKinds.join(',')}]` : '') +
'\n' +
` got hasShape=${hasShape} matches=${JSON.stringify(matches)}`
);
}
}
console.log('');
console.log('── account-number detector smoke ───────────────────────');
console.log('');
if (failed === 0) {
console.log(` ✓ all ${passed} scenarios passed`);
console.log('');
console.log('────────────────────────────────────────────────────────');
console.log(`✓ all ${passed} scenarios passed`);
process.exit(0);
} else {
console.log(` ${passed} passed, ${failed} failed`);
console.log('');
console.log(failures.join('\n'));
console.log('');
console.log('────────────────────────────────────────────────────────');
console.log(`${failed} of ${passed + failed} scenarios failed`);
process.exit(1);
}