mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 07:16:00 -06:00
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:
@@ -25,6 +25,7 @@ In more simpler terms, it's a tool that lets you look up information about IP ad
|
||||
The tool hosted by [rdap.org](https://client.rdap.org) is fantastic, but it's too simple, and isn't as overly-complicated and annoying as I want it to be. So, I built my own. Mine has dark mode.
|
||||
|
||||
But in all seriousness, my project does intend to have more features:
|
||||
|
||||
- Proxying: Some RDAP servers are inaccessible over the browser, and so proxying requests through a server is necessary.
|
||||
- Self Hosting: For those worried about privacy, the project can be self-hosted.
|
||||
- No Tracking: The project can't track you, because it's a static site. And in terms of the proxy, you can self-host if you're worried about that.
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"start": "next start",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:run": "vitest run --exclude '**/*.integration.test.ts'",
|
||||
"test:integration": "vitest run --include '**/*.integration.test.ts'",
|
||||
"test:all": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -45,7 +47,7 @@
|
||||
"eslint-config-next": "13.1.1",
|
||||
"happy-dom": "^20.0.8",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "^2.8.1",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1",
|
||||
"tailwindcss": "^3.2.0",
|
||||
"type-fest": "^4.18.2",
|
||||
|
||||
4963
pnpm-lock.yaml
generated
4963
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
||||
{url != undefined ? (
|
||||
<div className="pr-2">
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<LinkIcon className="h-5 w-5 mt-1 cursor-pointer" />
|
||||
<LinkIcon className="mt-1 h-5 w-5 cursor-pointer" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -43,14 +43,20 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
||||
<ClipboardDocumentIcon
|
||||
onClick={() => {
|
||||
// stringify the JSON object, then begin the async clipboard write
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 4)).then(() => {
|
||||
console.log('Copied to clipboard.');
|
||||
}, (err) => {
|
||||
if (err instanceof Error)
|
||||
console.error(`Failed to copy to clipboard (${err.toString()}).`);
|
||||
else
|
||||
console.error("Failed to copy to clipboard.");
|
||||
});
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify(data, null, 4))
|
||||
.then(
|
||||
() => {
|
||||
console.log("Copied to clipboard.");
|
||||
},
|
||||
(err) => {
|
||||
if (err instanceof Error)
|
||||
console.error(
|
||||
`Failed to copy to clipboard (${err.toString()}).`
|
||||
);
|
||||
else console.error("Failed to copy to clipboard.");
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
/>
|
||||
@@ -80,7 +86,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-w-full p-2 px-4 overflow-x-auto">
|
||||
<div className="max-w-full overflow-x-auto p-2 px-4">
|
||||
{showRaw ? (
|
||||
<pre className="scrollbar-thin m-2 max-h-[40rem] max-w-full overflow-y-auto whitespace-pre-wrap rounded">
|
||||
{JSON.stringify(data, null, 4)}
|
||||
|
||||
@@ -23,9 +23,11 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({
|
||||
return (
|
||||
<button onClick={toggleFormat}>
|
||||
<span className="dashed" title={date.toISOString()}>
|
||||
{showAbsolute
|
||||
? format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx")
|
||||
: <TimeAgo date={date} />}
|
||||
{showAbsolute ? (
|
||||
format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx")
|
||||
) : (
|
||||
<TimeAgo date={date} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -16,15 +16,22 @@ const ErrorCard: FunctionComponent<ErrorCardProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(className, "rounded-md border border-red-700/30 bg-zinc-800 pt-3 px-3 pb-1")}>
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
"rounded-md border border-red-700/30 bg-zinc-800 px-3 pb-1 pt-3"
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-300" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 text-sm text-red-300 w-full">
|
||||
<div className="ml-3 w-full text-sm text-red-300">
|
||||
<h3 className="font-medium text-red-200">{title}</h3>
|
||||
{description != undefined ? (
|
||||
<div className="mt-2 whitespace-pre-wrap max-h-24 overflow-y-auto w-full" >{description}</div>
|
||||
<div className="mt-2 max-h-24 w-full overflow-y-auto whitespace-pre-wrap">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2">
|
||||
{issues != undefined ? (
|
||||
|
||||
@@ -18,7 +18,7 @@ const Property: FunctionComponent<PropertyProps> = ({
|
||||
return (
|
||||
<>
|
||||
<dt className={clsx("font-medium", titleClass)}>{title}:</dt>
|
||||
<dd className={clsx("mt-2 mb-2 ml-6", valueClass)}>{children}</dd>
|
||||
<dd className={clsx("mb-2 ml-6 mt-2", valueClass)}>{children}</dd>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,9 @@ const PropertyListItem: FunctionComponent<{
|
||||
}> = ({ title, children }) => {
|
||||
return (
|
||||
<li>
|
||||
<span className="dashed" title={title}>{children}</span>
|
||||
<span className="dashed" title={title}>
|
||||
{children}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -193,14 +193,20 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
{selected == "auto" ? (
|
||||
// If the detected type was provided, then notate which in parentheses. Compact object naming might be better in the future.
|
||||
detectedType.isJust ? (
|
||||
<>Auto (<span className="animate-pulse">{targetShortNames[detectedType.value]}</span>)</>
|
||||
<>
|
||||
Auto (
|
||||
<span className="animate-pulse">
|
||||
{targetShortNames[detectedType.value]}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
objectNames["auto"]
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<LockClosedIcon
|
||||
className="mr-2.5 mb-1 inline h-4 w-4 animate-pulse text-zinc-500"
|
||||
className="mb-1 mr-2.5 inline h-4 w-4 animate-pulse text-zinc-500"
|
||||
aria-hidden
|
||||
/>
|
||||
{objectNames[selected]}
|
||||
@@ -292,7 +298,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="flex flex-wrap pt-3 pb-1 text-sm">
|
||||
<div className="flex flex-wrap pb-1 pt-3 text-sm">
|
||||
<div className="whitespace-nowrap">
|
||||
<input
|
||||
className="ml-2 mr-1 whitespace-nowrap text-zinc-800 accent-blue-700"
|
||||
|
||||
60
src/components/lookup/AutnumCard.tsx
Normal file
60
src/components/lookup/AutnumCard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import React from "react";
|
||||
import type { AutonomousNumber } from "@/types";
|
||||
import Events from "@/components/lookup/Events";
|
||||
import Property from "@/components/common/Property";
|
||||
import PropertyList from "@/components/common/PropertyList";
|
||||
import AbstractCard from "@/components/common/AbstractCard";
|
||||
|
||||
export type AutnumCardProps = {
|
||||
data: AutonomousNumber;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const AutnumCard: FunctionComponent<AutnumCardProps> = ({
|
||||
data,
|
||||
url,
|
||||
}: AutnumCardProps) => {
|
||||
const asnRange =
|
||||
data.startAutnum === data.endAutnum
|
||||
? `AS${data.startAutnum}`
|
||||
: `AS${data.startAutnum}-AS${data.endAutnum}`;
|
||||
|
||||
return (
|
||||
<AbstractCard
|
||||
data={data}
|
||||
url={url}
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">AUTONOMOUS SYSTEM</span>
|
||||
<span className="font-mono tracking-wide">{asnRange}</span>
|
||||
<span className="whitespace-nowrap">({data.handle})</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<dl>
|
||||
<Property title="Name">{data.name}</Property>
|
||||
<Property title="Handle">{data.handle}</Property>
|
||||
<Property title="ASN Range">
|
||||
{data.startAutnum === data.endAutnum
|
||||
? `AS${data.startAutnum}`
|
||||
: `AS${data.startAutnum} - AS${data.endAutnum}`}
|
||||
</Property>
|
||||
<Property title="Type">{data.type}</Property>
|
||||
<Property title="Country">{data.country.toUpperCase()}</Property>
|
||||
<Property title="Events">
|
||||
<Events key={0} data={data.events} />
|
||||
</Property>
|
||||
<PropertyList title="Status">
|
||||
{data.status.map((status, index) => (
|
||||
<PropertyList.Item key={index} title={status}>
|
||||
{status}
|
||||
</PropertyList.Item>
|
||||
))}
|
||||
</PropertyList>
|
||||
</dl>
|
||||
</AbstractCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutnumCard;
|
||||
@@ -23,7 +23,9 @@ const DomainCard: FunctionComponent<DomainProps> = ({
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">DOMAIN</span>
|
||||
<span className="font-mono tracking-wide">{data.ldhName ?? data.unicodeName}</span>
|
||||
<span className="font-mono tracking-wide">
|
||||
{data.ldhName ?? data.unicodeName}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">({data.handle})</span>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import DomainCard from "@/components/lookup/DomainCard";
|
||||
import IPCard from "@/components/lookup/IPCard";
|
||||
import AutnumCard from "@/components/lookup/AutnumCard";
|
||||
import type {
|
||||
Domain,
|
||||
AutonomousNumber,
|
||||
@@ -22,13 +23,17 @@ export type ObjectProps = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) => {
|
||||
const Generic: FunctionComponent<ObjectProps> = ({
|
||||
data,
|
||||
url,
|
||||
}: ObjectProps) => {
|
||||
switch (data.objectClassName) {
|
||||
case "domain":
|
||||
return <DomainCard url={url} data={data} />;
|
||||
case "ip network":
|
||||
return <IPCard url={url} data={data} />;
|
||||
case "autnum":
|
||||
return <AutnumCard url={url} data={data} />;
|
||||
case "entity":
|
||||
case "nameserver":
|
||||
default:
|
||||
|
||||
@@ -11,10 +11,7 @@ export type IPCardProps = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const IPCard: FunctionComponent<IPCardProps> = ({
|
||||
data,
|
||||
url,
|
||||
}: IPCardProps) => {
|
||||
const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
|
||||
return (
|
||||
<AbstractCard
|
||||
data={data}
|
||||
@@ -37,9 +34,7 @@ const IPCard: FunctionComponent<IPCardProps> = ({
|
||||
<Property title="Start Address">{data.startAddress}</Property>
|
||||
<Property title="End Address">{data.endAddress}</Property>
|
||||
<Property title="Type">{data.type}</Property>
|
||||
{data.country && (
|
||||
<Property title="Country">{data.country}</Property>
|
||||
)}
|
||||
{data.country && <Property title="Country">{data.country}</Property>}
|
||||
{data.parentHandle && (
|
||||
<Property title="Parent Handle">{data.parentHandle}</Property>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// see https://www.iana.org/assignments/rdap-json-values
|
||||
import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/types";
|
||||
import type {
|
||||
RdapStatusType,
|
||||
RootRegistryType,
|
||||
SimplifiedTargetType,
|
||||
} from "@/types";
|
||||
|
||||
export const rdapStatusInfo: Record<RdapStatusType, string> = {
|
||||
validated:
|
||||
|
||||
144
src/helpers.asn.test.ts
Normal file
144
src/helpers.asn.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { asnInRange } from "./helpers";
|
||||
|
||||
describe("asnInRange", () => {
|
||||
describe("basic matching", () => {
|
||||
it("should match ASN in single number range", () => {
|
||||
expect(asnInRange(100, "100-200")).toBe(true);
|
||||
expect(asnInRange(150, "100-200")).toBe(true);
|
||||
expect(asnInRange(200, "100-200")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match ASN outside single number range", () => {
|
||||
expect(asnInRange(99, "100-200")).toBe(false);
|
||||
expect(asnInRange(201, "100-200")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match ASN at boundaries", () => {
|
||||
expect(asnInRange(1, "1-10")).toBe(true);
|
||||
expect(asnInRange(10, "1-10")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match single ASN (same start and end)", () => {
|
||||
expect(asnInRange(12345, "12345-12345")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match single ASN outside", () => {
|
||||
expect(asnInRange(12346, "12345-12345")).toBe(false);
|
||||
expect(asnInRange(12344, "12345-12345")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world ASN ranges from IANA", () => {
|
||||
// ARIN ranges
|
||||
it("should match ARIN ASN ranges", () => {
|
||||
// ARIN typically has ranges like 1-1876, 1902-2042, etc.
|
||||
expect(asnInRange(100, "1-1876")).toBe(true);
|
||||
expect(asnInRange(1876, "1-1876")).toBe(true);
|
||||
expect(asnInRange(2000, "1902-2042")).toBe(true);
|
||||
});
|
||||
|
||||
// RIPE ranges
|
||||
it("should match RIPE ASN ranges", () => {
|
||||
// RIPE has ranges like 1877-1901, 2043-2109, etc.
|
||||
expect(asnInRange(1900, "1877-1901")).toBe(true);
|
||||
expect(asnInRange(2100, "2043-2109")).toBe(true);
|
||||
});
|
||||
|
||||
// APNIC ranges
|
||||
it("should match APNIC ASN ranges", () => {
|
||||
// APNIC has ranges like 2110-2136, 4608-4864, etc.
|
||||
expect(asnInRange(2120, "2110-2136")).toBe(true);
|
||||
expect(asnInRange(4700, "4608-4864")).toBe(true);
|
||||
});
|
||||
|
||||
// Well-known ASNs
|
||||
it("should match Google ASN (AS15169)", () => {
|
||||
// Google's ASN 15169 falls in range that includes it
|
||||
expect(asnInRange(15169, "15000-16000")).toBe(true);
|
||||
expect(asnInRange(15169, "15169-15169")).toBe(true);
|
||||
expect(asnInRange(15169, "15360-16383")).toBe(false); // Not in this range
|
||||
});
|
||||
|
||||
it("should match Cloudflare ASN (AS13335)", () => {
|
||||
// Cloudflare's ASN 13335 should be in ARIN range 13312-18431
|
||||
expect(asnInRange(13335, "13312-18431")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match Amazon ASN (AS16509)", () => {
|
||||
// Amazon's ASN 16509
|
||||
expect(asnInRange(16509, "15360-16383")).toBe(false);
|
||||
expect(asnInRange(16509, "16384-18431")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private ASN ranges", () => {
|
||||
it("should match 16-bit private ASN range", () => {
|
||||
// Private range: 64512-65534
|
||||
expect(asnInRange(64512, "64512-65534")).toBe(true);
|
||||
expect(asnInRange(65000, "64512-65534")).toBe(true);
|
||||
expect(asnInRange(65534, "64512-65534")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match outside private range", () => {
|
||||
expect(asnInRange(64511, "64512-65534")).toBe(false);
|
||||
expect(asnInRange(65535, "64512-65534")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match 32-bit private ASN range", () => {
|
||||
// Private range: 4200000000-4294967294
|
||||
expect(asnInRange(4200000000, "4200000000-4294967294")).toBe(true);
|
||||
expect(asnInRange(4250000000, "4200000000-4294967294")).toBe(true);
|
||||
expect(asnInRange(4294967294, "4200000000-4294967294")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("large ASN numbers (32-bit)", () => {
|
||||
it("should handle large ASN numbers", () => {
|
||||
expect(asnInRange(4200000000, "4200000000-4294967294")).toBe(true);
|
||||
expect(asnInRange(4294967295, "4200000000-4294967294")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle ASNs near 32-bit limit", () => {
|
||||
const maxAsn = 4294967295;
|
||||
expect(asnInRange(maxAsn, `${maxAsn}-${maxAsn}`)).toBe(true);
|
||||
expect(asnInRange(maxAsn - 1, `${maxAsn}-${maxAsn}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle invalid range format", () => {
|
||||
expect(asnInRange(100, "invalid")).toBe(false);
|
||||
expect(asnInRange(100, "100")).toBe(false);
|
||||
expect(asnInRange(100, "100-")).toBe(false);
|
||||
expect(asnInRange(100, "-100")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle negative numbers gracefully", () => {
|
||||
expect(asnInRange(-1, "1-100")).toBe(false);
|
||||
expect(asnInRange(50, "-100-100")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle reversed ranges (end < start)", () => {
|
||||
// Invalid range where end is less than start
|
||||
expect(asnInRange(150, "200-100")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle zero", () => {
|
||||
expect(asnInRange(0, "0-100")).toBe(true);
|
||||
expect(asnInRange(0, "1-100")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ASN number parsing", () => {
|
||||
it("should handle number inputs", () => {
|
||||
expect(asnInRange(12345, "10000-20000")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very large numbers", () => {
|
||||
const largeAsn = 4000000000;
|
||||
expect(asnInRange(largeAsn, "3000000000-4294967295")).toBe(true);
|
||||
expect(asnInRange(largeAsn, "1-1000000000")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
179
src/helpers.test.ts
Normal file
179
src/helpers.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ipv4InCIDR, ipv6InCIDR } from "./helpers";
|
||||
|
||||
describe("ipv4InCIDR", () => {
|
||||
describe("basic matching", () => {
|
||||
it("should match IP in /8 network", () => {
|
||||
expect(ipv4InCIDR("8.8.8.8", "8.0.0.0/8")).toBe(true);
|
||||
expect(ipv4InCIDR("8.255.255.255", "8.0.0.0/8")).toBe(true);
|
||||
expect(ipv4InCIDR("8.0.0.0", "8.0.0.0/8")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match IP outside /8 network", () => {
|
||||
expect(ipv4InCIDR("9.0.0.0", "8.0.0.0/8")).toBe(false);
|
||||
expect(ipv4InCIDR("7.255.255.255", "8.0.0.0/8")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match IP in /16 network", () => {
|
||||
expect(ipv4InCIDR("192.168.1.1", "192.168.0.0/16")).toBe(true);
|
||||
expect(ipv4InCIDR("192.168.255.255", "192.168.0.0/16")).toBe(true);
|
||||
expect(ipv4InCIDR("192.168.0.0", "192.168.0.0/16")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match IP outside /16 network", () => {
|
||||
expect(ipv4InCIDR("192.169.1.1", "192.168.0.0/16")).toBe(false);
|
||||
expect(ipv4InCIDR("192.167.1.1", "192.168.0.0/16")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match IP in /24 network", () => {
|
||||
expect(ipv4InCIDR("192.168.1.1", "192.168.1.0/24")).toBe(true);
|
||||
expect(ipv4InCIDR("192.168.1.255", "192.168.1.0/24")).toBe(true);
|
||||
expect(ipv4InCIDR("192.168.1.0", "192.168.1.0/24")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match IP outside /24 network", () => {
|
||||
expect(ipv4InCIDR("192.168.2.1", "192.168.1.0/24")).toBe(false);
|
||||
expect(ipv4InCIDR("192.168.0.255", "192.168.1.0/24")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match IP in /32 network (single host)", () => {
|
||||
expect(ipv4InCIDR("192.168.1.1", "192.168.1.1/32")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match different IP in /32 network", () => {
|
||||
expect(ipv4InCIDR("192.168.1.2", "192.168.1.1/32")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world RDAP bootstrap ranges", () => {
|
||||
// ARIN ranges (from IANA bootstrap data)
|
||||
it("should match Google DNS (8.8.8.8) in ARIN range", () => {
|
||||
expect(ipv4InCIDR("8.8.8.8", "8.0.0.0/8")).toBe(true);
|
||||
});
|
||||
|
||||
// APNIC ranges
|
||||
it("should match Cloudflare DNS (1.1.1.1) in APNIC range", () => {
|
||||
expect(ipv4InCIDR("1.1.1.1", "1.0.0.0/8")).toBe(true);
|
||||
});
|
||||
|
||||
// Private ranges
|
||||
it("should match private IPs in their ranges", () => {
|
||||
expect(ipv4InCIDR("10.0.0.1", "10.0.0.0/8")).toBe(true);
|
||||
expect(ipv4InCIDR("172.16.0.1", "172.16.0.0/12")).toBe(true);
|
||||
expect(ipv4InCIDR("192.168.0.1", "192.168.0.0/16")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle /0 (all IPs)", () => {
|
||||
expect(ipv4InCIDR("0.0.0.0", "0.0.0.0/0")).toBe(true);
|
||||
expect(ipv4InCIDR("255.255.255.255", "0.0.0.0/0")).toBe(true);
|
||||
expect(ipv4InCIDR("192.168.1.1", "0.0.0.0/0")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle invalid CIDR notation", () => {
|
||||
expect(ipv4InCIDR("192.168.1.1", "invalid")).toBe(false);
|
||||
expect(ipv4InCIDR("192.168.1.1", "192.168.1.0/-1")).toBe(false);
|
||||
expect(ipv4InCIDR("192.168.1.1", "192.168.1.0/33")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle malformed IPs", () => {
|
||||
expect(ipv4InCIDR("invalid", "192.168.1.0/24")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle partial IPs (wrong number of octets)", () => {
|
||||
expect(ipv4InCIDR("8.8", "8.0.0.0/8")).toBe(false);
|
||||
expect(ipv4InCIDR("192.168.1", "192.168.1.0/24")).toBe(false);
|
||||
expect(ipv4InCIDR("192.168.1.1.1", "192.168.1.0/24")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ipv6InCIDR", () => {
|
||||
describe("basic matching", () => {
|
||||
it("should match IPv6 in /32 network", () => {
|
||||
expect(ipv6InCIDR("2001:db8::", "2001:db8::/32")).toBe(true);
|
||||
expect(ipv6InCIDR("2001:db8:1234::", "2001:db8::/32")).toBe(true);
|
||||
expect(
|
||||
ipv6InCIDR("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", "2001:db8::/32")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match IPv6 outside /32 network", () => {
|
||||
expect(ipv6InCIDR("2001:db9::", "2001:db8::/32")).toBe(false);
|
||||
expect(ipv6InCIDR("2001:db7::", "2001:db8::/32")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match IPv6 in /64 network", () => {
|
||||
expect(
|
||||
ipv6InCIDR("2001:db8:1234:5678::", "2001:db8:1234:5678::/64")
|
||||
).toBe(true);
|
||||
expect(
|
||||
ipv6InCIDR("2001:db8:1234:5678:abcd::", "2001:db8:1234:5678::/64")
|
||||
).toBe(true);
|
||||
expect(
|
||||
ipv6InCIDR(
|
||||
"2001:db8:1234:5678:ffff:ffff:ffff:ffff",
|
||||
"2001:db8:1234:5678::/64"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match IPv6 outside /64 network", () => {
|
||||
expect(
|
||||
ipv6InCIDR("2001:db8:1234:5679::", "2001:db8:1234:5678::/64")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should match IPv6 in /128 network (single host)", () => {
|
||||
expect(ipv6InCIDR("2001:db8::1", "2001:db8::1/128")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match different IPv6 in /128 network", () => {
|
||||
expect(ipv6InCIDR("2001:db8::2", "2001:db8::1/128")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world RDAP bootstrap ranges", () => {
|
||||
it("should match Google IPv6 DNS in ARIN range", () => {
|
||||
// Google DNS: 2001:4860:4860::8888
|
||||
expect(ipv6InCIDR("2001:4860:4860::8888", "2001:4860::/32")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match Cloudflare IPv6 DNS in APNIC range", () => {
|
||||
// Cloudflare DNS: 2606:4700:4700::1111
|
||||
expect(ipv6InCIDR("2606:4700:4700::1111", "2606:4700::/32")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv6 shorthand notation", () => {
|
||||
it("should handle :: notation correctly", () => {
|
||||
expect(ipv6InCIDR("2001:db8::1", "2001:db8::/32")).toBe(true);
|
||||
expect(ipv6InCIDR("::1", "::1/128")).toBe(true);
|
||||
expect(ipv6InCIDR("::", "::/128")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle expanded vs compressed notation", () => {
|
||||
expect(
|
||||
ipv6InCIDR("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::/32")
|
||||
).toBe(true);
|
||||
expect(
|
||||
ipv6InCIDR("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0000/32")
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle invalid CIDR notation", () => {
|
||||
expect(ipv6InCIDR("2001:db8::1", "invalid")).toBe(false);
|
||||
expect(ipv6InCIDR("2001:db8::1", "2001:db8::/-1")).toBe(false);
|
||||
expect(ipv6InCIDR("2001:db8::1", "2001:db8::/129")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle malformed IPv6", () => {
|
||||
expect(ipv6InCIDR("invalid", "2001:db8::/32")).toBe(false);
|
||||
expect(ipv6InCIDR("zzzz::1", "2001:db8::/32")).toBe(false);
|
||||
expect(ipv6InCIDR("2001:xyz::1", "2001:db8::/32")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ 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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
RegisterSchema,
|
||||
RootRegistryEnum,
|
||||
} from "@/schema";
|
||||
import { truncated, ipv4InCIDR, ipv6InCIDR } from "@/helpers";
|
||||
import { truncated, ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/helpers";
|
||||
import type { ZodSchema } from "zod";
|
||||
import type { ParsedGeneric } from "@/components/lookup/Generic";
|
||||
import { Maybe, Result } from "true-myth";
|
||||
@@ -53,9 +53,12 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
|
||||
useCallback(async () => {
|
||||
if (currentType != null) return Maybe.just(currentType);
|
||||
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) => Maybe.just(type));
|
||||
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(
|
||||
Maybe.nothing(),
|
||||
(type) => Maybe.just(type)
|
||||
);
|
||||
setUriType(uri);
|
||||
}, [target, currentType, getTypeEasy])
|
||||
}, [target, currentType, getTypeEasy]);
|
||||
|
||||
// Fetch & load a specific registry's data into memory.
|
||||
async function loadBootstrap(type: RootRegistryType, force = false) {
|
||||
@@ -85,17 +88,16 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
if (registryDataRef.current[type] == null) await loadBootstrap(type);
|
||||
const registry = registryDataRef.current[type];
|
||||
if (registry == null)
|
||||
throw new Error(
|
||||
`Could not load bootstrap data for ${type} registry.`
|
||||
);
|
||||
throw new Error(`Could not load bootstrap data for ${type} registry.`);
|
||||
return registry;
|
||||
}
|
||||
|
||||
async function getTypeEasy(target: string): Promise<Result<TargetType, Error>> {
|
||||
async function getTypeEasy(
|
||||
target: string
|
||||
): Promise<Result<TargetType, Error>> {
|
||||
return getType(target, getRegistry);
|
||||
}
|
||||
|
||||
|
||||
function getRegistryURL(
|
||||
type: RootRegistryType,
|
||||
lookupTarget: string
|
||||
@@ -120,7 +122,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
throw new Error(`No matching domain found.`);
|
||||
case "ip4": {
|
||||
// Extract the IP address without CIDR suffix for matching
|
||||
const ipAddress = lookupTarget.split('/')[0] ?? lookupTarget;
|
||||
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
||||
for (const bootstrapItem of bootstrap.services) {
|
||||
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
|
||||
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
|
||||
@@ -132,7 +134,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
}
|
||||
case "ip6": {
|
||||
// Extract the IP address without CIDR suffix for matching
|
||||
const ipAddress = lookupTarget.split('/')[0] ?? lookupTarget;
|
||||
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
||||
for (const bootstrapItem of bootstrap.services) {
|
||||
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
|
||||
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
|
||||
@@ -142,10 +144,29 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
}
|
||||
throw new Error(`No matching IPv6 registry found for ${lookupTarget}.`);
|
||||
}
|
||||
case "autnum": {
|
||||
// Extract ASN number from "AS12345" format
|
||||
const asnMatch = lookupTarget.match(/^AS(\d+)$/i);
|
||||
if (!asnMatch || !asnMatch[1]) {
|
||||
throw new Error(`Invalid ASN format: ${lookupTarget}`);
|
||||
}
|
||||
|
||||
const asnNumber = parseInt(asnMatch[1], 10);
|
||||
if (isNaN(asnNumber)) {
|
||||
throw new Error(`Invalid ASN number: ${lookupTarget}`);
|
||||
}
|
||||
|
||||
for (const bootstrapItem of bootstrap.services) {
|
||||
// bootstrapItem[0] contains ASN ranges like ["64512-65534", "13312-18431"]
|
||||
if (bootstrapItem[0].some((range) => asnInRange(asnNumber, range))) {
|
||||
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
||||
break typeSwitch;
|
||||
}
|
||||
}
|
||||
throw new Error(`No matching registry found for ${lookupTarget}.`);
|
||||
}
|
||||
case "entity":
|
||||
throw new Error(`No matching entity found.`);
|
||||
case "autnum":
|
||||
throw new Error(`No matching autnum found.`);
|
||||
default:
|
||||
throw new Error("Invalid lookup target provided.");
|
||||
}
|
||||
@@ -404,7 +425,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
}
|
||||
}
|
||||
|
||||
return { error, setTarget, setTargetType, submit, currentType: uriType, getType: getTypeEasy };
|
||||
return {
|
||||
error,
|
||||
setTarget,
|
||||
setTargetType,
|
||||
submit,
|
||||
currentType: uriType,
|
||||
getType: getTypeEasy,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLookup;
|
||||
|
||||
@@ -14,17 +14,25 @@ const Index: NextPage = () => {
|
||||
const [detectedType, setDetectedType] = useState<Maybe<TargetType>>(
|
||||
Maybe.nothing()
|
||||
);
|
||||
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing());
|
||||
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(
|
||||
Maybe.nothing()
|
||||
);
|
||||
const [isLoading, setLoading] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>rdap.xevion.dev</title>
|
||||
<meta name="description" content="A custom, private RDAP lookup client built by Xevion." />
|
||||
<meta
|
||||
name="description"
|
||||
content="A custom, private RDAP lookup client built by Xevion."
|
||||
/>
|
||||
<meta property="og:url" content="https://rdap.xevion.dev" />
|
||||
<meta property="og:title" content="RDAP | by Xevion.dev" />
|
||||
<meta property="og:description" content="A custom, private RDAP lookup client built by Xevion." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A custom, private RDAP lookup client built by Xevion."
|
||||
/>
|
||||
<meta property="og:site_name" content="rdap.xevion.dev" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -34,8 +42,17 @@ const Index: NextPage = () => {
|
||||
/>
|
||||
</Head>
|
||||
<nav className="bg-zinc-850 px-5 py-4 shadow-sm">
|
||||
<span className="text-white text-xl font-medium" style={{ fontSize: "larger" }}>
|
||||
<a href="https://github.com/Xevion/rdap">rdap</a><a href={"https://xevion.dev"} className="text-zinc-400 hover:animate-pulse">.xevion.dev</a>
|
||||
<span
|
||||
className="text-xl font-medium text-white"
|
||||
style={{ fontSize: "larger" }}
|
||||
>
|
||||
<a href="https://github.com/Xevion/rdap">rdap</a>
|
||||
<a
|
||||
href={"https://xevion.dev"}
|
||||
className="text-zinc-400 hover:animate-pulse"
|
||||
>
|
||||
.xevion.dev
|
||||
</a>
|
||||
</span>
|
||||
</nav>
|
||||
<div className="mx-auto max-w-screen-sm px-5 lg:max-w-screen-md xl:max-w-screen-lg">
|
||||
|
||||
53
src/rdap.integration.test.ts
Normal file
53
src/rdap.integration.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getType } from "./rdap";
|
||||
import type { Register, RootRegistryType } from "./types";
|
||||
import { registryURLs } from "./constants";
|
||||
|
||||
// Integration tests that fetch real IANA bootstrap data
|
||||
// These are slower but test against actual registries
|
||||
// Note: Uses Node.js environment instead of happy-dom to allow real network requests
|
||||
|
||||
const registryCache = new Map<RootRegistryType, Register>();
|
||||
|
||||
async function getRealRegistry(type: RootRegistryType): Promise<Register> {
|
||||
if (registryCache.has(type)) {
|
||||
return registryCache.get(type)!;
|
||||
}
|
||||
|
||||
const response = await fetch(registryURLs[type]);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${type} registry: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Register;
|
||||
registryCache.set(type, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
describe("getType - Integration tests with real registries", () => {
|
||||
it("should detect entity with real entity registry", async () => {
|
||||
// Test with a known entity tag (RIPE)
|
||||
const result = await getType("TEST-RIPE", getRealRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("entity");
|
||||
}
|
||||
}, 10000); // Longer timeout for network call
|
||||
|
||||
it("should detect entity with ARIN tag", async () => {
|
||||
const result = await getType("NET-ARIN", getRealRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("entity");
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("should not detect invalid entity tag", async () => {
|
||||
const result = await getType("INVALID-NOTREAL", getRealRegistry);
|
||||
// Should either error or detect as something else, but not entity
|
||||
if (result.isOk) {
|
||||
expect(result.value).not.toBe("entity");
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
409
src/rdap.test.ts
Normal file
409
src/rdap.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { getType } from "./rdap";
|
||||
import type { Register } from "./types";
|
||||
|
||||
// Mock registry getter (matches real IANA structure: [email, tags, urls])
|
||||
const mockRegistry: Register = {
|
||||
description: "Test registry",
|
||||
publication: "2024-01-01",
|
||||
version: "1.0",
|
||||
services: [
|
||||
[
|
||||
["test@example.com"], // email
|
||||
["RIPE", "APNIC"], // tags
|
||||
["https://rdap.example.com/"] // urls
|
||||
]
|
||||
],
|
||||
};
|
||||
|
||||
const mockGetRegistry = vi.fn(() => Promise.resolve(mockRegistry));
|
||||
|
||||
describe("getType - IP address detection", () => {
|
||||
describe("IPv4 detection", () => {
|
||||
it("should detect standard IPv4 addresses", async () => {
|
||||
const result = await getType("192.168.1.1", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip4");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect IPv4 with CIDR notation", async () => {
|
||||
const result = await getType("192.168.1.0/24", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip4");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect various IPv4 addresses", async () => {
|
||||
const ips = [
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"255.255.255.255",
|
||||
"0.0.0.0",
|
||||
];
|
||||
|
||||
for (const ip of ips) {
|
||||
const result = await getType(ip, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip4");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect IPv4 with various CIDR prefixes", async () => {
|
||||
const cidrs = [
|
||||
"192.168.1.0/8",
|
||||
"10.0.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"8.8.8.0/24",
|
||||
"1.1.1.1/32",
|
||||
];
|
||||
|
||||
for (const cidr of cidrs) {
|
||||
const result = await getType(cidr, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip4");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv6 detection", () => {
|
||||
it("should detect standard IPv6 addresses", async () => {
|
||||
const result = await getType("2001:db8::1", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip6");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect IPv6 with CIDR notation", async () => {
|
||||
const result = await getType("2001:db8::/32", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip6");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect various IPv6 addresses", async () => {
|
||||
const ips = [
|
||||
"2001:4860:4860::8888", // Google DNS
|
||||
"2606:4700:4700::1111", // Cloudflare DNS
|
||||
"::1", // Localhost
|
||||
"::", // All zeros
|
||||
"fe80::1", // Link-local
|
||||
"2001:db8:85a3::8a2e:370:7334", // Full notation
|
||||
];
|
||||
|
||||
for (const ip of ips) {
|
||||
const result = await getType(ip, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip6");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect IPv6 with various CIDR prefixes", async () => {
|
||||
const cidrs = ["2001:db8::/32", "2001:4860::/32", "fe80::/10", "::1/128"];
|
||||
|
||||
for (const cidr of cidrs) {
|
||||
const result = await getType(cidr, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip6");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - Domain detection", () => {
|
||||
it("should detect standard domains", async () => {
|
||||
const result = await getType("example.com", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("domain");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect various domain formats", async () => {
|
||||
const domains = [
|
||||
"google.com",
|
||||
"www.example.com",
|
||||
"sub.domain.example.com",
|
||||
"test-domain.com",
|
||||
"example123.org",
|
||||
"a.b.c.d.example.net",
|
||||
];
|
||||
|
||||
for (const domain of domains) {
|
||||
const result = await getType(domain, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("domain");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - ASN detection", () => {
|
||||
it("should detect standard ASN format", async () => {
|
||||
const result = await getType("AS12345", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("autnum");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect various ASN formats", async () => {
|
||||
const asns = [
|
||||
"AS1",
|
||||
"AS13335", // Cloudflare
|
||||
"AS15169", // Google
|
||||
"AS8075", // Microsoft
|
||||
"AS16509", // Amazon
|
||||
"AS999999",
|
||||
];
|
||||
|
||||
for (const asn of asns) {
|
||||
const result = await getType(asn, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("autnum");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - TLD detection", () => {
|
||||
it("should detect TLD format", async () => {
|
||||
const result = await getType(".com", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("tld");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect various TLDs", async () => {
|
||||
const tlds = [".com", ".org", ".net", ".dev", ".io", ".ai", ".co"];
|
||||
|
||||
for (const tld of tlds) {
|
||||
const result = await getType(tld, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("tld");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - URL detection", () => {
|
||||
it("should detect HTTP URLs", async () => {
|
||||
const result = await getType("http://example.com", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("url");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect HTTPS URLs", async () => {
|
||||
const result = await getType("https://example.com", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("url");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect RDAP URLs", async () => {
|
||||
const urls = [
|
||||
"https://rdap.arin.net/registry/ip/8.8.8.8",
|
||||
"http://rdap.apnic.net/ip/1.1.1.1",
|
||||
"https://rdap.org/domain/example.com",
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
const result = await getType(url, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("url");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - JSON detection", () => {
|
||||
it("should detect JSON objects", async () => {
|
||||
const result = await getType(
|
||||
'{"objectClassName":"domain"}',
|
||||
mockGetRegistry
|
||||
);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect various JSON formats", async () => {
|
||||
const jsons = [
|
||||
"{}",
|
||||
'{"key": "value"}',
|
||||
'{"objectClassName":"ip network"}',
|
||||
'{"handle":"TEST"}',
|
||||
];
|
||||
|
||||
for (const json of jsons) {
|
||||
const result = await getType(json, mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("json");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - Invalid inputs", () => {
|
||||
it("should return error for empty string", async () => {
|
||||
const result = await getType("", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
});
|
||||
|
||||
it("should return error for unrecognized format", async () => {
|
||||
const result = await getType("not-a-valid-input!!@@##", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
});
|
||||
|
||||
describe("Invalid IPv4 addresses", () => {
|
||||
it("should return error for IPv4 with octet > 255", async () => {
|
||||
const result = await getType("256.1.1.1", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
if (result.isErr) {
|
||||
expect(result.error.message).toContain("Invalid IPv4 address");
|
||||
expect(result.error.message).toContain("octet");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return error for IPv4 with octet 999", async () => {
|
||||
const result = await getType("192.999.1.1", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
if (result.isErr) {
|
||||
expect(result.error.message).toContain("Invalid IPv4 address");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return error for IPv4 with invalid CIDR prefix", async () => {
|
||||
const result = await getType("192.168.1.1/33", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
if (result.isErr) {
|
||||
expect(result.error.message).toContain("CIDR prefix");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return error for IPv4 with negative CIDR", async () => {
|
||||
const result = await getType("192.168.1.1/-1", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invalid IPv6 addresses", () => {
|
||||
it("should return error for IPv6 with multiple ::", async () => {
|
||||
const result = await getType("2001::db8::1", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
if (result.isErr) {
|
||||
expect(result.error.message).toContain("::");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return error for IPv6 with invalid CIDR prefix", async () => {
|
||||
const result = await getType("2001:db8::1/129", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
if (result.isErr) {
|
||||
expect(result.error.message).toContain("CIDR prefix");
|
||||
}
|
||||
});
|
||||
|
||||
it("should not match completely invalid hex strings as IPv6", async () => {
|
||||
// "gggg" doesn't match the basic IPv6 pattern, so it won't be detected as IPv6
|
||||
const result = await getType("gggg::1", mockGetRegistry);
|
||||
expect(result.isErr).toBe(true);
|
||||
// Won't have IPv6-specific error since it didn't match the pattern
|
||||
if (result.isErr) {
|
||||
expect(result.error.message).toContain("No patterns matched");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - Type detection priority", () => {
|
||||
it("should detect URL before domain", async () => {
|
||||
const result = await getType("https://example.com", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("url");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect JSON before domain", async () => {
|
||||
const result = await getType('{"key":"value"}', mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect TLD before domain", async () => {
|
||||
const result = await getType(".com", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("tld");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect IP before domain", async () => {
|
||||
const result = await getType("8.8.8.8", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("ip4");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getType - Case sensitivity", () => {
|
||||
it("should detect uppercase domains", async () => {
|
||||
const result = await getType("GOOGLE.COM", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("domain");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect mixed case domains", async () => {
|
||||
const result = await getType("GoOgLe.CoM", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("domain");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect lowercase ASN", async () => {
|
||||
const result = await getType("as12345", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("autnum");
|
||||
}
|
||||
});
|
||||
|
||||
it("should detect uppercase ASN", async () => {
|
||||
const result = await getType("AS12345", mockGetRegistry);
|
||||
expect(result.isOk).toBe(true);
|
||||
if (result.isOk) {
|
||||
expect(result.value).toBe("autnum");
|
||||
}
|
||||
});
|
||||
});
|
||||
185
src/rdap.ts
185
src/rdap.ts
@@ -762,64 +762,171 @@ type ValidatorArgs = {
|
||||
getRegistry: (type: RootRegistryType) => Promise<Register>;
|
||||
};
|
||||
|
||||
const TypeValidators: Record<
|
||||
TargetType,
|
||||
(args: ValidatorArgs) => Promise<boolean>
|
||||
> = {
|
||||
autnum: ({ value }) => Promise.resolve(/^AS\d+$/.test(value)),
|
||||
ip4: ({ value }) =>
|
||||
Promise.resolve(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/?\d*$/.test(value)),
|
||||
ip6: ({ value }) => Promise.resolve(/^[0-9a-f:]{2,}\/?\d*$/.test(value)),
|
||||
url: ({ value }) => Promise.resolve(/^https?:/.test(value)),
|
||||
json: ({ value }) => Promise.resolve(/^{/.test(value)),
|
||||
tld: ({ value }) => Promise.resolve(/^\.\w+$/.test(value)),
|
||||
domain: ({ value }) => {
|
||||
return Promise.resolve(
|
||||
/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/.test(
|
||||
value
|
||||
)
|
||||
);
|
||||
},
|
||||
entity: async ({ value, getRegistry }) => {
|
||||
// Ensure the entity handle is in the correct format
|
||||
const result = value.match(/^\w+-(\w+)$/);
|
||||
if (result === null || result.length <= 1 || result[1] == undefined) return false;
|
||||
/**
|
||||
* Validator result types:
|
||||
* - false: didn't match this type, try next validator
|
||||
* - true: matched and valid
|
||||
* - string: matched but invalid (error message)
|
||||
*/
|
||||
type ValidatorResult = boolean | string;
|
||||
|
||||
// Check if the entity object tag is real
|
||||
try {
|
||||
/**
|
||||
* Type validators in priority order (most specific to most generic).
|
||||
* Order matters: url/json/tld are checked before domain to avoid false matches.
|
||||
*/
|
||||
const TypeValidators = new Map<
|
||||
TargetType,
|
||||
(args: ValidatorArgs) => Promise<ValidatorResult>
|
||||
>([
|
||||
["url", ({ value }) => Promise.resolve(/^https?:/.test(value))],
|
||||
["json", ({ value }) => Promise.resolve(/^{/.test(value))],
|
||||
["tld", ({ value }) => Promise.resolve(/^\.\w+$/.test(value))],
|
||||
[
|
||||
"ip4",
|
||||
({ value }) => {
|
||||
// Basic format check
|
||||
const match = value.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(\/\d{1,2})?$/
|
||||
);
|
||||
if (!match) return Promise.resolve(false);
|
||||
|
||||
// Validate each octet is 0-255
|
||||
const octets = [match[1], match[2], match[3], match[4]];
|
||||
for (let i = 0; i < octets.length; i++) {
|
||||
const octet = parseInt(octets[i] ?? "", 10);
|
||||
if (isNaN(octet) || octet < 0 || octet > 255) {
|
||||
return Promise.resolve(
|
||||
`Invalid IPv4 address: octet ${i + 1} (${octets[i]}) must be 0-255`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate CIDR prefix if present
|
||||
if (match[5]) {
|
||||
const prefix = parseInt(match[5].substring(1), 10);
|
||||
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
|
||||
return Promise.resolve(
|
||||
"Invalid IPv4 address: CIDR prefix must be 0-32"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
],
|
||||
[
|
||||
"ip6",
|
||||
({ value }) => {
|
||||
// Basic format check (hex characters, colons, optional CIDR)
|
||||
const match = value.match(/^([0-9a-fA-F:]+)(\/\d{1,3})?$/);
|
||||
if (!match) return Promise.resolve(false);
|
||||
|
||||
const ipPart = match[1] ?? "";
|
||||
|
||||
// Check for invalid characters
|
||||
if (!/^[0-9a-fA-F:]+$/.test(ipPart)) {
|
||||
return Promise.resolve(
|
||||
"Invalid IPv6 address: contains invalid characters"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate double :: only appears once
|
||||
const doubleColonCount = (ipPart.match(/::/g) || []).length;
|
||||
if (doubleColonCount > 1) {
|
||||
return Promise.resolve("Invalid IPv6 address: :: can only appear once");
|
||||
}
|
||||
|
||||
// Validate CIDR prefix if present
|
||||
if (match[2]) {
|
||||
const prefix = parseInt(match[2].substring(1), 10);
|
||||
if (isNaN(prefix) || prefix < 0 || prefix > 128) {
|
||||
return Promise.resolve(
|
||||
"Invalid IPv6 address: CIDR prefix must be 0-128"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
],
|
||||
["autnum", ({ value }) => Promise.resolve(/^AS\d+$/i.test(value))],
|
||||
[
|
||||
"entity",
|
||||
async ({ value, getRegistry }) => {
|
||||
// Ensure the entity handle is in the correct format
|
||||
const result = value.match(/^\w+-(\w+)$/);
|
||||
if (result === null || result.length <= 1 || result[1] == undefined)
|
||||
return false;
|
||||
|
||||
// Check if the entity object tag is real
|
||||
try {
|
||||
const registry = await getRegistry("entity");
|
||||
|
||||
// Check each service to see if tag starts with inputted value
|
||||
// Check each service to see if tag matches
|
||||
// Entity registry structure: [email, tags, urls]
|
||||
for (const service of registry.services) {
|
||||
const tags = service[1];
|
||||
console.log({tags, result});
|
||||
if (tags.some((tag) => tag.startsWith(result[1] as string))) return true;
|
||||
const tags = service[1]; // Tags are at index 1 (0=email, 1=tags, 2=urls)
|
||||
if (
|
||||
tags.some(
|
||||
(tag) => tag.toUpperCase() === (result[1] as string).toUpperCase()
|
||||
)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error(new Error("Failed to fetch entity registry", {cause: e}));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
new Error("Failed to fetch entity registry", { cause: e })
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
registrar: ({ }) => Promise.resolve(false),
|
||||
};
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"domain",
|
||||
({ value }) => {
|
||||
// Case-insensitive domain matching with support for multiple labels
|
||||
// Matches: example.com, www.example.com, a.b.c.d.example.net, etc.
|
||||
return Promise.resolve(
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(
|
||||
value
|
||||
)
|
||||
);
|
||||
},
|
||||
],
|
||||
["registrar", () => Promise.resolve(false)],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Retrieves the precise type of a given value based on matching patterns.
|
||||
*
|
||||
* Validators are checked in priority order (most specific to most generic).
|
||||
* If a validator matches but the value is invalid, an error is returned immediately
|
||||
* without trying subsequent validators.
|
||||
*
|
||||
* @param value - The value to determine the type for.
|
||||
* @returns A `Result` object containing the determined `TargetType` if a match is found,
|
||||
* otherwise an `Error` object.
|
||||
* or an `Error` if the value is invalid or no patterns match.
|
||||
*/
|
||||
export async function getType(
|
||||
value: string,
|
||||
getRegistry: (type: RootRegistryType) => Promise<Register>
|
||||
): Promise<Result<TargetType, Error>> {
|
||||
for (const [type, validator] of Object.entries(TypeValidators)) {
|
||||
if (await validator({ value, getRegistry })) return Result.ok(type);
|
||||
for (const [type, validator] of TypeValidators.entries()) {
|
||||
const result = await validator({ value, getRegistry });
|
||||
|
||||
if (result === false) {
|
||||
// Didn't match this type, try next validator
|
||||
continue;
|
||||
} else if (result === true) {
|
||||
// Matched and valid
|
||||
return Result.ok(type);
|
||||
} else {
|
||||
// Matched but invalid (result is error message)
|
||||
return Result.err(new Error(result));
|
||||
}
|
||||
}
|
||||
|
||||
return Result.err(new Error("No patterns matched the input"));
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export const EventSchema = z.object({
|
||||
export const NoticeSchema = z.object({
|
||||
description: z.string().array(), // de jure required
|
||||
title: z.string().optional(),
|
||||
links: z.array(LinkSchema).optional()
|
||||
links: z.array(LinkSchema).optional(),
|
||||
});
|
||||
export type Notice = z.infer<typeof NoticeSchema>;
|
||||
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700\&family=Inter:wght@100..900&display=swap");
|
||||
|
||||
dd {
|
||||
margin: 0.5em 0 1em 2em;
|
||||
margin: 0.5em 0 1em 2em;
|
||||
}
|
||||
|
||||
.dashed {
|
||||
border-bottom: 1px dashed silver;
|
||||
border-bottom: 1px dashed silver;
|
||||
}
|
||||
|
||||
body {
|
||||
color-scheme: dark;
|
||||
@apply font-sans bg-zinc-900 text-white;
|
||||
color-scheme: dark;
|
||||
@apply bg-zinc-900 font-sans text-white;
|
||||
}
|
||||
|
||||
dd,
|
||||
dl {
|
||||
white-space: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
||||
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
|
||||
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
colors: {
|
||||
zinc: {
|
||||
850: "#1D1D20",
|
||||
},
|
||||
},
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ["IBM Plex Mono", ...defaultTheme.fontFamily.mono],
|
||||
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
colors: {
|
||||
zinc: {
|
||||
850: "#1D1D20",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
environment: "happy-dom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user