test: add comprehensive testing infrastructure with critical bug fixes

- Add Vitest testing framework with 88 passing tests across 4 test files
- Fix critical entity validator bug (service array index)
- Implement validator architecture with 'matched but invalid' state support
- Add strict IPv4/IPv6 validation with detailed error messages
- Add case-insensitive domain and ASN matching
- Add explicit validator priority ordering (url→json→tld→ip→domain)
- Add integration tests with real IANA registry validation
- Add AutnumCard component for AS number display
- Update dependencies: prettier 2.8.1→2.8.8

Test coverage:
- helpers.test.ts: IPv4/IPv6 CIDR matching (27 tests)
- helpers.asn.test.ts: ASN range validation (22 tests)
- rdap.test.ts: Type detection with edge cases (36 tests)
- rdap.integration.test.ts: Real IANA registry tests (3 tests)

Bug fixes:
- Entity validator now correctly uses service[1] for tags (0=email, 1=tags, 2=urls)
- IPv4 validation rejects octets >255 with specific error messages
- IPv6 validation checks for invalid hex chars and multiple ::
- Domain regex supports multi-label domains (a.b.c.d.example.net)
- Type detection priority prevents URL/JSON false matches as domains
This commit is contained in:
2025-10-22 01:23:15 -05:00
parent 09cd0bf49b
commit 5fb095a498
27 changed files with 4559 additions and 1745 deletions

View File

@@ -3,8 +3,8 @@ import type { Entries } from "type-fest";
declare global {
interface ObjectConstructor {
entries<T extends object>(o: T): Entries<T>
}
entries<T extends object>(o: T): Entries<T>;
}
}
export function truthy(value: string | null | undefined) {
@@ -45,10 +45,11 @@ export function preventDefault(event: SyntheticEvent | Event) {
* Convert an IPv4 address string to a 32-bit integer
*/
function ipv4ToInt(ip: string): number {
const parts = ip.split('.').map(Number);
const parts = ip.split(".").map(Number);
if (parts.length !== 4) return 0;
const [a, b, c, d] = parts;
if (a === undefined || b === undefined || c === undefined || d === undefined) return 0;
if (a === undefined || b === undefined || c === undefined || d === undefined)
return 0;
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}
@@ -59,16 +60,21 @@ function ipv4ToInt(ip: string): number {
* @returns true if the IP is within the CIDR range
*/
export function ipv4InCIDR(ip: string, cidr: string): boolean {
const [rangeIp, prefixLenStr] = cidr.split('/');
const prefixLen = parseInt(prefixLenStr ?? '', 10);
const [rangeIp, prefixLenStr] = cidr.split("/");
const prefixLen = parseInt(prefixLenStr ?? "", 10);
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
return false;
}
// Special case: /0 matches all IPs
if (prefixLen === 0) {
return true;
}
const ipInt = ipv4ToInt(ip);
const rangeInt = ipv4ToInt(rangeIp);
const mask = (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
const mask = (0xffffffff << (32 - prefixLen)) >>> 0;
return (ipInt & mask) === (rangeInt & mask);
}
@@ -79,7 +85,7 @@ export function ipv4InCIDR(ip: string, cidr: string): boolean {
function ipv6ToBigInt(ip: string): bigint {
// Expand :: notation
const expandedIp = expandIPv6(ip);
const parts = expandedIp.split(':');
const parts = expandedIp.split(":");
let result = BigInt(0);
for (const part of parts) {
@@ -92,16 +98,19 @@ function ipv6ToBigInt(ip: string): bigint {
* Expand IPv6 address shorthand notation
*/
function expandIPv6(ip: string): string {
if (ip.includes('::')) {
const [left, right] = ip.split('::');
const leftParts = left ? left.split(':') : [];
const rightParts = right ? right.split(':') : [];
if (ip.includes("::")) {
const [left, right] = ip.split("::");
const leftParts = left ? left.split(":") : [];
const rightParts = right ? right.split(":") : [];
const missingParts = 8 - leftParts.length - rightParts.length;
const middleParts: string[] = Array(missingParts).fill('0') as string[];
const middleParts: string[] = Array(missingParts).fill("0") as string[];
const allParts = [...leftParts, ...middleParts, ...rightParts];
return allParts.map((p: string) => p.padStart(4, '0')).join(':');
return allParts.map((p: string) => p.padStart(4, "0")).join(":");
}
return ip.split(':').map((p: string) => p.padStart(4, '0')).join(':');
return ip
.split(":")
.map((p: string) => p.padStart(4, "0"))
.join(":");
}
/**
@@ -111,8 +120,8 @@ function expandIPv6(ip: string): string {
* @returns true if the IP is within the CIDR range
*/
export function ipv6InCIDR(ip: string, cidr: string): boolean {
const [rangeIp, prefixLenStr] = cidr.split('/');
const prefixLen = parseInt(prefixLenStr ?? '', 10);
const [rangeIp, prefixLenStr] = cidr.split("/");
const prefixLen = parseInt(prefixLenStr ?? "", 10);
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 128) {
return false;
@@ -121,7 +130,7 @@ export function ipv6InCIDR(ip: string, cidr: string): boolean {
try {
const ipInt = ipv6ToBigInt(ip);
const rangeInt = ipv6ToBigInt(rangeIp);
const maxMask = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF');
const maxMask = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
const mask = (maxMask << BigInt(128 - prefixLen)) & maxMask;
return (ipInt & mask) === (rangeInt & mask);
@@ -129,3 +138,30 @@ export function ipv6InCIDR(ip: string, cidr: string): boolean {
return false;
}
}
/**
* Check if an ASN falls within a range
* @param asn The ASN number to check (e.g., 13335 for Cloudflare)
* @param range The range to check against (e.g., "13312-18431")
* @returns true if the ASN is within the range
*/
export function asnInRange(asn: number, range: string): boolean {
const parts = range.split("-");
if (parts.length !== 2) {
return false;
}
const start = parseInt(parts[0] ?? "", 10);
const end = parseInt(parts[1] ?? "", 10);
if (isNaN(start) || isNaN(end) || start < 0 || end < 0 || start > end) {
return false;
}
if (asn < 0) {
return false;
}
return asn >= start && asn <= end;
}