diff --git a/.changeset/network-consistency-checker.md b/.changeset/network-consistency-checker.md new file mode 100644 index 00000000..38fd40f6 --- /dev/null +++ b/.changeset/network-consistency-checker.md @@ -0,0 +1,5 @@ +--- +"@adcp/client": minor +--- + +Add NetworkConsistencyChecker for validating managed publisher network deployments. Detects orphaned pointers, stale pointers, missing pointers, schema errors, and unreachable agent endpoints. Available as both a library import and CLI command (`adcp check-network`). diff --git a/bin/adcp.js b/bin/adcp.js index 6828d7ec..ee6e5581 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -704,6 +704,7 @@ USAGE: COMMANDS: storyboard Test agent flows (run, list, show, step) + check-network Validate managed publisher network deployment comply [options] DEPRECATED — use "storyboard run" instead test [scenario] Run individual test scenarios (legacy) registry Brand/property registry lookups @@ -1264,6 +1265,150 @@ async function handleStoryboardStepCmd(args) { process.exit(result.passed ? 0 : 3); } +async function handleCheckNetworkCommand(args) { + if (args.includes('--help') || args.includes('-h')) { + console.log(` +Validate a managed publisher network deployment. + +USAGE: + adcp check-network --url [options] + adcp check-network --domains [options] + +OPTIONS: + --url URL URL of the authoritative adagents.json + --domains LIST Comma-separated domains to check + --concurrency N Max parallel fetches (default: 10) + --timeout MS Per-request timeout in ms (default: 10000) + --json Output raw JSON + --help, -h Show this help + +EXAMPLES: + adcp check-network --url https://network.example.com/adagents/v2/adagents.json + adcp check-network --url https://network.example.com/adagents.json --domains extra1.com,extra2.com + adcp check-network --domains cookingdaily.com,gardenweekly.com +`); + return; + } + + const urlIndex = args.indexOf('--url'); + const domainsIndex = args.indexOf('--domains'); + const concurrencyIndex = args.indexOf('--concurrency'); + const timeoutIndex = args.indexOf('--timeout'); + const jsonOutput = args.includes('--json'); + + const url = urlIndex !== -1 ? args[urlIndex + 1] : undefined; + const domainsStr = domainsIndex !== -1 ? args[domainsIndex + 1] : undefined; + const concurrency = concurrencyIndex !== -1 ? parseInt(args[concurrencyIndex + 1], 10) : undefined; + const timeout = timeoutIndex !== -1 ? parseInt(args[timeoutIndex + 1], 10) : undefined; + + if (concurrency !== undefined && (isNaN(concurrency) || concurrency < 1)) { + console.error('ERROR: --concurrency must be a positive integer'); + process.exit(2); + } + if (timeout !== undefined && (isNaN(timeout) || timeout < 1)) { + console.error('ERROR: --timeout must be a positive integer'); + process.exit(2); + } + + if (!url && !domainsStr) { + console.error('ERROR: --url or --domains is required\n'); + console.error('Run "adcp check-network --help" for usage'); + process.exit(2); + } + + const domains = domainsStr + ? domainsStr + .split(',') + .map(d => d.trim()) + .filter(Boolean) + : undefined; + + const { NetworkConsistencyChecker } = require('../dist/lib/index.js'); + + const progressHandler = jsonOutput + ? undefined + : ({ phase, completed, total }) => { + process.stderr.write(`\r ${phase}: ${completed}/${total}`); + if (completed === total) process.stderr.write('\n'); + }; + + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: url, + domains, + concurrency, + timeoutMs: timeout, + logLevel: 'warn', + onProgress: progressHandler, + }); + + try { + const report = await checker.check(); + + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + process.exit(report.summary.totalIssues > 0 ? 1 : 0); + return; + } + + // Pretty-print report + console.log(`\nNetwork Consistency Report`); + console.log(`${'='.repeat(50)}`); + console.log(`Checked at: ${report.checkedAt}`); + console.log(`Authoritative URL: ${report.authoritativeUrl}`); + console.log( + `Coverage: ${(report.coverage * 100).toFixed(1)}% (${report.summary.validPointers}/${report.summary.totalDomains})` + ); + + if (report.schemaErrors.length > 0) { + console.log(`\nSchema Errors (${report.schemaErrors.length}):`); + for (const err of report.schemaErrors) { + console.log(` - ${err.field}: ${err.message}`); + } + } + + if (report.agentHealth.length > 0) { + console.log(`\nAgent Health:`); + for (const agent of report.agentHealth) { + const status = agent.reachable ? 'OK' : 'UNREACHABLE'; + const detail = agent.error ? ` (${agent.error})` : agent.statusCode ? ` (HTTP ${agent.statusCode})` : ''; + console.log(` ${status} ${agent.url}${detail}`); + } + } + + if (report.missingPointers.length > 0) { + console.log(`\nMissing Pointers (${report.missingPointers.length}):`); + for (const p of report.missingPointers) { + console.log(` - ${p.domain}: ${p.error}`); + } + } + + if (report.stalePointers.length > 0) { + console.log(`\nStale Pointers (${report.stalePointers.length}):`); + for (const p of report.stalePointers) { + console.log(` - ${p.domain}: points to ${p.pointerUrl}, expected ${p.expectedUrl}`); + } + } + + if (report.orphanedPointers.length > 0) { + console.log(`\nOrphaned Pointers (${report.orphanedPointers.length}):`); + for (const p of report.orphanedPointers) { + console.log(` - ${p.domain}: points to ${p.pointerUrl} but not listed in properties`); + } + } + + if (report.summary.totalIssues === 0) { + console.log(`\nAll checks passed.`); + } else { + console.log(`\n${report.summary.totalIssues} issue(s) found.`); + } + + process.exit(report.summary.totalIssues > 0 ? 1 : 0); + } catch (error) { + console.error(`ERROR: ${error.message}`); + process.exit(2); + } +} + async function main() { const args = process.argv.slice(2); @@ -1288,6 +1433,11 @@ async function main() { return; } + if (args[0] === 'check-network') { + await handleCheckNetworkCommand(args.slice(1)); + return; + } + // Handle help if (args.includes('--help') || args.includes('-h') || args.length === 0) { printUsage(); diff --git a/src/lib/discovery/network-consistency-checker.ts b/src/lib/discovery/network-consistency-checker.ts new file mode 100644 index 00000000..19028660 --- /dev/null +++ b/src/lib/discovery/network-consistency-checker.ts @@ -0,0 +1,670 @@ +/** + * Network Consistency Checker for AdCP + * + * Validates managed publisher network deployments by checking that: + * 1. The authoritative adagents.json is well-formed + * 2. Each property domain has a valid pointer file + * 3. No orphaned or stale pointer files exist + * 4. All authorized agent endpoints are reachable + */ + +import { createLogger, type LogLevel } from '../utils/logger'; +import { LIBRARY_VERSION } from '../version'; +import { validateUserAgent } from '../utils/validate-user-agent'; +import { validateAgentUrl } from '../validation'; +import type { AdAgentsJson, AuthorizedAgent, Property } from './types'; + +// ====== Configuration ====== + +/** Progress update emitted after each domain or agent check completes. */ +export interface CheckProgress { + phase: 'pointers' | 'orphans' | 'agents'; + completed: number; + total: number; + domain?: string; +} + +export interface NetworkConsistencyCheckerConfig { + /** URL of the authoritative adagents.json file */ + authoritativeUrl?: string; + /** Domains to check (if no authoritativeUrl, fetches pointer from first domain) */ + domains?: string[]; + /** Max parallel fetches (default 10, max 50) */ + concurrency?: number; + /** Per-request timeout in ms (default 10000) */ + timeoutMs?: number; + logLevel?: LogLevel; + userAgent?: string; + /** Called after each domain/agent check completes. */ + onProgress?: (progress: CheckProgress) => void; +} + +// ====== Report types ====== + +export interface OrphanedPointer { + domain: string; + pointerUrl: string; +} + +export interface StalePointer { + domain: string; + pointerUrl: string; + expectedUrl: string; +} + +export interface MissingPointer { + domain: string; + error: string; +} + +export interface SchemaError { + field: string; + message: string; +} + +export interface AgentHealthResult { + url: string; + reachable: boolean; + statusCode?: number; + error?: string; +} + +export type DomainStatus = 'ok' | 'missing_pointer' | 'stale_pointer' | 'orphaned_pointer' | 'error'; + +export interface DomainDetail { + domain: string; + status: DomainStatus; + pointerUrl?: string; + errors: string[]; +} + +export interface CheckSummary { + totalDomains: number; + validPointers: number; + orphanedPointers: number; + stalePointers: number; + missingPointers: number; + schemaErrors: number; + unreachableAgents: number; + totalIssues: number; +} + +export interface NetworkCheckReport { + checkedAt: string; + authoritativeUrl: string; + coverage: number; + summary: CheckSummary; + orphanedPointers: OrphanedPointer[]; + stalePointers: StalePointer[]; + missingPointers: MissingPointer[]; + schemaErrors: SchemaError[]; + agentHealth: AgentHealthResult[]; + domains: DomainDetail[]; +} + +// ====== Implementation ====== + +const DEFAULT_CONCURRENCY = 10; +const MAX_CONCURRENCY = 50; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 1_048_576; // 1 MB + +const FETCH_HEADERS = { + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', +}; + +export class NetworkConsistencyChecker { + private readonly authoritativeUrl?: string; + private readonly domains: string[]; + private readonly concurrency: number; + private readonly timeoutMs: number; + private readonly logger: ReturnType; + private readonly userAgentHeader: string; + private readonly fromHeader: string; + private readonly onProgress?: (progress: CheckProgress) => void; + + constructor(config: NetworkConsistencyCheckerConfig) { + if (!config.authoritativeUrl && (!config.domains || config.domains.length === 0)) { + throw new Error('Either authoritativeUrl or domains must be provided'); + } + if (config.userAgent) { + validateUserAgent(config.userAgent); + } + + this.authoritativeUrl = config.authoritativeUrl; + this.domains = config.domains ?? []; + this.concurrency = Math.min(config.concurrency ?? DEFAULT_CONCURRENCY, MAX_CONCURRENCY); + this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (this.concurrency < 1) throw new Error('concurrency must be >= 1'); + if (this.timeoutMs < 1) throw new Error('timeoutMs must be >= 1'); + this.logger = createLogger({ level: config.logLevel ?? 'warn' }).child('NetworkConsistencyChecker'); + this.userAgentHeader = `adcp-network-checker/${LIBRARY_VERSION} (+https://adcontextprotocol.org)`; + this.fromHeader = config.userAgent + ? `adcp-network-checker@adcontextprotocol.org (${config.userAgent}; v${LIBRARY_VERSION})` + : `adcp-network-checker@adcontextprotocol.org (v${LIBRARY_VERSION})`; + this.onProgress = config.onProgress; + } + + async check(): Promise { + const report: NetworkCheckReport = { + checkedAt: new Date().toISOString(), + authoritativeUrl: '', + coverage: 0, + summary: { + totalDomains: 0, + validPointers: 0, + orphanedPointers: 0, + stalePointers: 0, + missingPointers: 0, + schemaErrors: 0, + unreachableAgents: 0, + totalIssues: 0, + }, + orphanedPointers: [], + stalePointers: [], + missingPointers: [], + schemaErrors: [], + agentHealth: [], + domains: [], + }; + + // Step 1: Resolve and fetch the authoritative file + const { url: resolvedUrl, data: authData } = await this.fetchAuthoritative(report); + report.authoritativeUrl = resolvedUrl; + + if (!authData) { + this.computeSummary(report, 0); + return report; + } + + // Step 2: Validate authoritative file schema + this.validateSchema(authData, report); + + // Step 3: Extract domains from authoritative properties + const authoritativeDomains = this.extractDomains(authData.properties ?? []); + + // Step 4: Check agent endpoint health + if (authData.authorized_agents && authData.authorized_agents.length > 0) { + report.agentHealth = await this.checkAgentHealth(authData.authorized_agents); + } + + // Step 5: Check pointer files on authoritative domains + await this.checkPointers(resolvedUrl, authoritativeDomains, report); + + // Step 6: Check for orphaned pointers (domains not in authoritative file) + const extraDomains = this.domains.filter(d => !authoritativeDomains.has(d)); + if (extraDomains.length > 0) { + await this.checkOrphanedPointers(resolvedUrl, extraDomains, report); + } + + // Step 7: Compute coverage and summary + const total = authoritativeDomains.size; + const valid = report.domains.filter(d => d.status === 'ok').length; + report.coverage = total > 0 ? valid / total : 0; + this.computeSummary(report, total); + + return report; + } + + private computeSummary(report: NetworkCheckReport, totalDomains: number): void { + const unreachableAgents = report.agentHealth.filter(a => !a.reachable).length; + report.summary = { + totalDomains, + validPointers: report.domains.filter(d => d.status === 'ok').length, + orphanedPointers: report.orphanedPointers.length, + stalePointers: report.stalePointers.length, + missingPointers: report.missingPointers.length, + schemaErrors: report.schemaErrors.length, + unreachableAgents, + totalIssues: + report.schemaErrors.length + + report.missingPointers.length + + report.stalePointers.length + + report.orphanedPointers.length + + unreachableAgents, + }; + } + + // ---- Internal methods ---- + + private async fetchAuthoritative(report: NetworkCheckReport): Promise<{ url: string; data: AdAgentsJson | null }> { + let url = this.authoritativeUrl; + + // If no authoritative URL, discover it from the first domain's pointer + if (!url) { + const firstDomain = this.domains[0]; + try { + const pointerData = await this.fetchJson(`https://${firstDomain}/.well-known/adagents.json`); + if (pointerData.authoritative_location) { + url = pointerData.authoritative_location; + } else { + return { url: `https://${firstDomain}/.well-known/adagents.json`, data: pointerData }; + } + } catch (error) { + report.schemaErrors.push({ + field: 'authoritative_location', + message: `Failed to discover authoritative URL from ${firstDomain}: ${this.sanitizeError(error)}`, + }); + return { url: '', data: null }; + } + } + + try { + const data = await this.fetchJson(url); + + // Follow at most one authoritative_location redirect + if (data.authoritative_location && !data.authorized_agents) { + const redirectUrl = data.authoritative_location; + if (!redirectUrl.startsWith('https://')) { + report.schemaErrors.push({ + field: 'authoritative_location', + message: `authoritative_location must use HTTPS: ${redirectUrl}`, + }); + return { url, data: null }; + } + if (redirectUrl === url) { + report.schemaErrors.push({ + field: 'authoritative_location', + message: 'authoritative_location points to itself', + }); + return { url, data: null }; + } + const redirectData = await this.fetchJson(redirectUrl); + // Do not follow further redirects from the redirect target + return { url: redirectUrl, data: redirectData }; + } + + return { url, data }; + } catch (error) { + report.schemaErrors.push({ + field: '$root', + message: `Failed to fetch authoritative file: ${this.sanitizeError(error)}`, + }); + return { url: url ?? '', data: null }; + } + } + + private validateSchema(data: AdAgentsJson, report: NetworkCheckReport): void { + if (!data.authorized_agents || !Array.isArray(data.authorized_agents)) { + report.schemaErrors.push({ + field: 'authorized_agents', + message: 'Missing or invalid authorized_agents array', + }); + } else { + data.authorized_agents.forEach((agent, i) => { + if (!agent.url) { + report.schemaErrors.push({ + field: `authorized_agents[${i}].url`, + message: 'Missing required url field', + }); + } + if (!agent.authorized_for) { + report.schemaErrors.push({ + field: `authorized_agents[${i}].authorized_for`, + message: 'Missing required authorized_for field', + }); + } + }); + } + + if (data.properties && Array.isArray(data.properties)) { + data.properties.forEach((prop, i) => { + if (!prop.name) { + report.schemaErrors.push({ + field: `properties[${i}].name`, + message: 'Missing required name field', + }); + } + if (!prop.property_type) { + report.schemaErrors.push({ + field: `properties[${i}].property_type`, + message: 'Missing required property_type field', + }); + } + if (!prop.identifiers || !Array.isArray(prop.identifiers) || prop.identifiers.length === 0) { + report.schemaErrors.push({ + field: `properties[${i}].identifiers`, + message: 'Missing or empty identifiers array', + }); + } + }); + } + } + + private extractDomains(properties: Property[]): Set { + const domains = new Set(); + for (const prop of properties) { + for (const id of prop.identifiers ?? []) { + if (id.type === 'domain' || id.type === 'subdomain') { + domains.add(id.value.toLowerCase()); + } + } + if (prop.publisher_domain) { + domains.add(prop.publisher_domain.toLowerCase()); + } + } + return domains; + } + + private async checkAgentHealth(agents: AuthorizedAgent[]): Promise { + let completed = 0; + return this.runConcurrent(agents, async agent => { + const result = await this.probeAgent(agent); + completed++; + this.onProgress?.({ phase: 'agents', completed, total: agents.length }); + return result; + }); + } + + private async probeAgent(agent: AuthorizedAgent): Promise { + try { + validateAgentUrl(agent.url); + } catch (error) { + return { + url: agent.url, + reachable: false, + error: error instanceof Error ? error.message : String(error), + }; + } + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + let response = await fetch(agent.url, { + method: 'HEAD', + signal: controller.signal, + redirect: 'manual', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + + // Follow one redirect with SSRF validation + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get?.('location'); + if (!location) { + return { url: agent.url, reachable: false, error: 'Redirect with no Location header' }; + } + const redirectUrl = new URL(location, agent.url).toString(); + if (!redirectUrl.startsWith('https://')) { + return { url: agent.url, reachable: false, error: 'Redirect to non-HTTPS URL' }; + } + validateAgentUrl(redirectUrl); + response = await fetch(redirectUrl, { + method: 'HEAD', + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + } + + return { + url: agent.url, + reachable: response.ok || response.status === 405, // 405 = HEAD rejected but server is alive + statusCode: response.status, + }; + } finally { + clearTimeout(timeout); + } + } catch (error) { + return { + url: agent.url, + reachable: false, + error: this.sanitizeError(error), + }; + } + } + + private async checkPointers( + authoritativeUrl: string, + authoritativeDomains: Set, + report: NetworkCheckReport + ): Promise { + const domains = Array.from(authoritativeDomains); + + let completed = 0; + const results = await this.runConcurrent(domains, async domain => { + const result = await this.checkDomainPointer(domain, authoritativeUrl); + completed++; + this.onProgress?.({ phase: 'pointers', completed, total: domains.length, domain }); + return result; + }); + + for (const result of results) { + report.domains.push(result.detail); + if (result.missing) report.missingPointers.push(result.missing); + if (result.stale) report.stalePointers.push(result.stale); + } + } + + private async checkDomainPointer( + domain: string, + authoritativeUrl: string + ): Promise<{ + detail: DomainDetail; + missing?: MissingPointer; + stale?: StalePointer; + }> { + const url = `https://${domain}/.well-known/adagents.json`; + + try { + const data = await this.fetchJson(url); + + if (data.authoritative_location) { + if (data.authoritative_location === authoritativeUrl) { + return { + detail: { + domain, + status: 'ok', + pointerUrl: data.authoritative_location, + errors: [], + }, + }; + } else { + return { + detail: { + domain, + status: 'stale_pointer', + pointerUrl: data.authoritative_location, + errors: [`Pointer references ${data.authoritative_location}, expected ${authoritativeUrl}`], + }, + stale: { + domain, + pointerUrl: data.authoritative_location, + expectedUrl: authoritativeUrl, + }, + }; + } + } + + // No authoritative_location — the domain hosts its own file + if (url === authoritativeUrl) { + return { + detail: { domain, status: 'ok', errors: [] }, + }; + } + + return { + detail: { + domain, + status: 'stale_pointer', + errors: [`adagents.json exists but has no authoritative_location pointing to ${authoritativeUrl}`], + }, + stale: { + domain, + pointerUrl: url, + expectedUrl: authoritativeUrl, + }, + }; + } catch (error) { + const msg = this.sanitizeError(error); + this.logger.debug(`Failed to fetch pointer from ${domain}: ${msg}`); + return { + detail: { domain, status: 'missing_pointer', errors: [msg] }, + missing: { domain, error: msg }, + }; + } + } + + private async checkOrphanedPointers( + authoritativeUrl: string, + extraDomains: string[], + report: NetworkCheckReport + ): Promise { + let completed = 0; + const results = await this.runConcurrent( + extraDomains, + async ( + domain + ): Promise<{ + domain: string; + orphaned: boolean; + pointerUrl: string; + } | null> => { + try { + const data = await this.fetchJson(`https://${domain}/.well-known/adagents.json`); + const result = + data.authoritative_location === authoritativeUrl + ? { domain, orphaned: true, pointerUrl: data.authoritative_location } + : null; + return result; + } catch { + return null; + } finally { + completed++; + this.onProgress?.({ phase: 'orphans', completed, total: extraDomains.length, domain }); + } + } + ); + + for (const result of results) { + if (result?.orphaned) { + report.orphanedPointers.push({ + domain: result.domain, + pointerUrl: result.pointerUrl, + }); + report.domains.push({ + domain: result.domain, + status: 'orphaned_pointer', + pointerUrl: result.pointerUrl, + errors: ['Domain has pointer file but is not listed in authoritative properties'], + }); + } + } + } + + private async fetchJson(url: string): Promise { + validateAgentUrl(url); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + let response = await fetch(url, { + signal: controller.signal, + redirect: 'manual', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + + // Follow one HTTP redirect with SSRF validation + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get?.('location'); + if (!location) { + throw new Error(`HTTP ${response.status} redirect with no Location header`); + } + const redirectUrl = new URL(location, url).toString(); + if (!redirectUrl.startsWith('https://')) { + throw new Error('Redirect to non-HTTPS URL not allowed'); + } + validateAgentUrl(redirectUrl); + response = await fetch(redirectUrl, { + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const contentLength = response.headers.get?.('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { + throw new Error('Response too large'); + } + const text = await response.text(); + if (Buffer.byteLength(text, 'utf-8') > MAX_RESPONSE_BYTES) { + throw new Error('Response too large'); + } + return JSON.parse(text) as T; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`Request timed out after ${this.timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } + } + + private sanitizeError(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'AbortError') return 'Request timed out'; + if (error.message.startsWith('HTTP ')) return error.message; + if (error.message.startsWith('Request timed out')) return error.message; + if (error.message.includes('ECONNREFUSED')) return 'Connection refused'; + if (error.message.includes('ENOTFOUND')) return 'DNS resolution failed'; + if (error.message.includes('certificate')) return 'TLS error'; + if (error.message.includes('Response too large')) return 'Response too large'; + if (error.message.includes('not allowed')) return error.message; + if (error.message.includes('must use HTTPS')) return error.message; + if (error.message.includes('no Location header')) return error.message; + } + return 'Request failed'; + } + + /** + * Run async operations with concurrency limit, preserving input order. + */ + private async runConcurrent(items: T[], fn: (item: T) => Promise): Promise { + const results = new Array(items.length); + let active = 0; + let nextIdx = 0; + + return new Promise((resolve, reject) => { + if (items.length === 0) return resolve([]); + + const next = (): void => { + while (active < this.concurrency && nextIdx < items.length) { + const i = nextIdx++; + active++; + fn(items[i]!) + .then(r => { + results[i] = r; + active--; + if (nextIdx >= items.length && active === 0) { + resolve(results); + } else { + next(); + } + }) + .catch(reject); + } + }; + next(); + }); + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 621a056e..c33960a6 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -69,6 +69,20 @@ export { type CrawlResult, type PropertyCrawlerConfig, } from './discovery/property-crawler'; +export { + NetworkConsistencyChecker, + type NetworkConsistencyCheckerConfig, + type NetworkCheckReport, + type CheckSummary, + type CheckProgress, + type OrphanedPointer, + type StalePointer, + type MissingPointer, + type SchemaError, + type AgentHealthResult, + type DomainDetail, + type DomainStatus, +} from './discovery/network-consistency-checker'; export type { Property, PropertyIdentifier, diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js new file mode 100644 index 00000000..6db29ff8 --- /dev/null +++ b/test/lib/network-consistency-checker.test.js @@ -0,0 +1,735 @@ +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); + +let originalFetch; + +beforeEach(() => { + originalFetch = global.fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +/** + * Helper: build a mock fetch that dispatches by URL pattern. + * @param {Record} routes - URL substring → response config or handler + */ +function routedFetch(routes) { + global.fetch = async (url, options) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + for (const [pattern, handler] of Object.entries(routes)) { + if (urlStr.includes(pattern)) { + const config = typeof handler === 'function' ? handler(urlStr, options) : handler; + const status = config.status || 200; + // Support redirect responses (301/302 with location header) + if (config.location) { + return { + ok: false, + status, + statusText: config.statusText || 'Moved', + headers: new Map([['location', config.location]]), + json: async () => null, + text: async () => '', + }; + } + const body = JSON.stringify(config.data); + return { + ok: status >= 200 && status < 300, + status, + statusText: config.statusText || 'OK', + headers: new Map([['content-length', String(body.length)]]), + json: async () => config.data, + text: async () => body, + }; + } + } + // Default: 404 + return { + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + json: async () => { + throw new Error('Not Found'); + }, + text: async () => 'Not Found', + }; + }; +} + +function makeAuthoritativeFile(properties, agents) { + return { + $schema: 'https://adcontextprotocol.org/schemas/v1/adagents.json', + authorized_agents: agents || [{ url: 'https://seller.example.com/mcp', authorized_for: 'Programmatic sales' }], + properties: properties || [ + { + property_type: 'website', + name: 'cookingdaily.com', + identifiers: [{ type: 'domain', value: 'cookingdaily.com' }], + publisher_domain: 'cookingdaily.com', + }, + { + property_type: 'website', + name: 'gardenweekly.com', + identifiers: [{ type: 'domain', value: 'gardenweekly.com' }], + publisher_domain: 'gardenweekly.com', + }, + ], + }; +} + +function makePointer(authoritativeUrl) { + return { + authoritative_location: authoritativeUrl, + }; +} + +describe('NetworkConsistencyChecker', () => { + const AUTH_URL = 'https://network.example.com/adagents.json'; + + describe('core checks', () => { + test('clean network — all pointers valid, 100% coverage', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.ok(report.checkedAt, 'checkedAt should be set'); + assert.ok(!isNaN(Date.parse(report.checkedAt)), 'checkedAt should be valid ISO 8601'); + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.orphanedPointers.length, 0); + assert.strictEqual(report.stalePointers.length, 0); + assert.strictEqual(report.missingPointers.length, 0); + assert.strictEqual(report.schemaErrors.length, 0); + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + assert.strictEqual(report.domains.length, 2); + assert.ok(report.domains.every(d => d.status === 'ok')); + assert.ok(report.domains.every(d => d.errors.length === 0)); + // Summary + assert.strictEqual(report.summary.totalDomains, 2); + assert.strictEqual(report.summary.validPointers, 2); + assert.strictEqual(report.summary.totalIssues, 0); + }); + + test('orphaned pointer — domain points here but not in properties', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'cookingdaily.com', + identifiers: [{ type: 'domain', value: 'cookingdaily.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'orphan.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + domains: ['cookingdaily.com', 'orphan.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.orphanedPointers.length, 1); + assert.strictEqual(report.orphanedPointers[0].domain, 'orphan.com'); + assert.strictEqual(report.orphanedPointers[0].pointerUrl, AUTH_URL); + // Coverage is based on authoritative domains only (cookingdaily.com), not orphans + assert.strictEqual(report.coverage, 1); + const orphanDetail = report.domains.find(d => d.domain === 'orphan.com'); + assert.strictEqual(orphanDetail.status, 'orphaned_pointer'); + }); + + test('stale pointer — domain points to different authoritative URL', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { + data: makePointer('https://old-network.example.com/adagents.json'), + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.stalePointers.length, 1); + assert.strictEqual(report.stalePointers[0].domain, 'gardenweekly.com'); + assert.strictEqual(report.stalePointers[0].pointerUrl, 'https://old-network.example.com/adagents.json'); + assert.strictEqual(report.stalePointers[0].expectedUrl, AUTH_URL); + assert.strictEqual(report.coverage, 0.5); + }); + + test('missing pointer — domain returns 404', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.missingPointers[0].domain, 'gardenweekly.com'); + assert.strictEqual(report.coverage, 0.5); + }); + + test('schema errors — authoritative file missing required fields', async () => { + const badAuthFile = { + properties: [ + { + identifiers: [{ type: 'domain', value: 'example.com' }], + }, + ], + }; + + routedFetch({ + 'network.example.com/adagents.json': { data: badAuthFile }, + 'example.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.schemaErrors.length, 3); + const fields = report.schemaErrors.map(e => e.field); + assert.ok(fields.includes('authorized_agents')); + assert.ok(fields.includes('properties[0].name')); + assert.ok(fields.includes('properties[0].property_type')); + }); + + test('mixed results — combination of issues', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'good.com', + identifiers: [{ type: 'domain', value: 'good.com' }], + }, + { + property_type: 'website', + name: 'stale.com', + identifiers: [{ type: 'domain', value: 'stale.com' }], + }, + { + property_type: 'website', + name: 'missing.com', + identifiers: [{ type: 'domain', value: 'missing.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'good.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'stale.com/.well-known/adagents.json': { + data: makePointer('https://other.example.com/adagents.json'), + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.stalePointers.length, 1); + assert.ok(Math.abs(report.coverage - 1 / 3) < 0.01, `Expected ~33% coverage, got ${report.coverage}`); + assert.strictEqual(report.summary.totalDomains, 3); + assert.strictEqual(report.summary.validPointers, 1); + assert.strictEqual(report.summary.missingPointers, 1); + assert.strictEqual(report.summary.stalePointers, 1); + assert.strictEqual(report.summary.totalIssues, 2); + }); + }); + + describe('agent health', () => { + test('unreachable agent — endpoint returns 500', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://healthy.example.com/mcp', authorized_for: 'Sales' }, + { url: 'https://broken.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'healthy.example.com/mcp': { data: {} }, + 'broken.example.com/mcp': { status: 500, statusText: 'Internal Server Error', data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 2); + const healthy = report.agentHealth.find(a => a.url.includes('healthy')); + const broken = report.agentHealth.find(a => a.url.includes('broken')); + assert.strictEqual(healthy.reachable, true); + assert.strictEqual(healthy.error, undefined); + assert.strictEqual(broken.reachable, false); + assert.strictEqual(broken.statusCode, 500); + }); + + test('agent returning 405 is treated as reachable', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://no-head.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'no-head.example.com/mcp': { status: 405, statusText: 'Method Not Allowed', data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + assert.strictEqual(report.agentHealth[0].statusCode, 405); + }); + }); + + describe('authoritative file resolution', () => { + test('domains-only mode — discovers authoritative URL from first domain pointer', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'site-a.com', + identifiers: [{ type: 'domain', value: 'site-a.com' }], + }, + { + property_type: 'website', + name: 'site-b.com', + identifiers: [{ type: 'domain', value: 'site-b.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + routedFetch({ + 'site-a.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'network.example.com/adagents.json': { data: authFile }, + 'site-b.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + domains: ['site-a.com', 'site-b.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.strictEqual(report.coverage, 1); + }); + + test('domains-only mode — first domain serves full file directly', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'primary.com', + identifiers: [{ type: 'domain', value: 'primary.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + routedFetch({ + 'primary.com/.well-known/adagents.json': { data: authFile }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + domains: ['primary.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, 'https://primary.com/.well-known/adagents.json'); + assert.strictEqual(report.schemaErrors.length, 0); + }); + + test('authoritative URL fetch failure returns early with schema error', async () => { + global.fetch = async () => { + throw new Error('ECONNREFUSED'); + }; + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.coverage, 0); + assert.ok(report.schemaErrors.length >= 1); + assert.ok(report.schemaErrors.some(e => e.field === '$root')); + assert.strictEqual(report.domains.length, 0); + }); + + test('self-referential authoritative_location is reported as schema error', async () => { + routedFetch({ + 'network.example.com/adagents.json': { + data: { authoritative_location: AUTH_URL }, + }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.ok(report.schemaErrors.some(e => e.message.includes('points to itself'))); + assert.strictEqual(report.coverage, 0); + }); + + test('non-HTTPS authoritative_location redirect is rejected', async () => { + routedFetch({ + 'network.example.com/adagents.json': { + data: { authoritative_location: 'http://insecure.example.com/adagents.json' }, + }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.ok(report.schemaErrors.some(e => e.message.includes('must use HTTPS'))); + assert.strictEqual(report.coverage, 0); + }); + + test('authoritative_location redirect is followed one hop', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'pub.com', + identifiers: [{ type: 'domain', value: 'pub.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + const redirectUrl = 'https://canonical.example.com/adagents.json'; + + routedFetch({ + 'network.example.com/adagents.json': { + data: { authoritative_location: redirectUrl }, + }, + 'canonical.example.com/adagents.json': { data: authFile }, + 'pub.com/.well-known/adagents.json': { data: makePointer(redirectUrl) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, redirectUrl); + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.schemaErrors.length, 0); + }); + }); + + describe('domain pointer edge cases', () => { + test('domain without authoritative_location is stale', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'standalone.com', + identifiers: [{ type: 'domain', value: 'standalone.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'standalone.com/.well-known/adagents.json': { + data: { + authorized_agents: [{ url: 'https://other.example.com/mcp', authorized_for: 'Sales' }], + properties: [ + { + property_type: 'website', + name: 'standalone.com', + identifiers: [{ type: 'domain', value: 'standalone.com' }], + }, + ], + }, + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.stalePointers.length, 1); + assert.strictEqual(report.stalePointers[0].domain, 'standalone.com'); + assert.strictEqual(report.stalePointers[0].expectedUrl, AUTH_URL); + assert.strictEqual(report.coverage, 0); + }); + + test('subdomain identifier type is extracted for pointer checks', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'Blog', + identifiers: [{ type: 'subdomain', value: 'blog.example.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'blog.example.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.domains.length, 1); + assert.strictEqual(report.domains[0].status, 'ok'); + }); + }); + + describe('HTTP redirect following', () => { + test('follows one redirect on pointer fetch (CDN www redirect)', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'example.com', + identifiers: [{ type: 'domain', value: 'example.com' }], + }, + ]); + + // Use a function handler to disambiguate bare domain vs www + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'example.com/.well-known/adagents.json': urlStr => { + if (urlStr === 'https://www.example.com/.well-known/adagents.json') { + return { data: makePointer(AUTH_URL) }; + } + return { status: 301, location: 'https://www.example.com/.well-known/adagents.json' }; + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.missingPointers.length, 0); + assert.strictEqual(report.domains[0].status, 'ok'); + }); + + test('follows one redirect on agent health check', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://agent.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + // Agent endpoint redirects + 'agent.example.com/mcp': { + status: 301, + location: 'https://agent-v2.example.com/mcp', + }, + 'agent-v2.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + }); + + test('rejects redirect to non-HTTPS URL on pointer fetch', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'insecure.com', + identifiers: [{ type: 'domain', value: 'insecure.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'insecure.com/.well-known/adagents.json': { + status: 301, + location: 'http://insecure.com/.well-known/adagents.json', + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.coverage, 0); + }); + }); + + describe('progress callback', () => { + test('onProgress is called for each domain check', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const events = []; + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + onProgress: progress => events.push(progress), + }); + + await checker.check(); + + const pointerEvents = events.filter(e => e.phase === 'pointers'); + const agentEvents = events.filter(e => e.phase === 'agents'); + assert.strictEqual(pointerEvents.length, 2); + assert.strictEqual(agentEvents.length, 1); + assert.ok(pointerEvents.every(e => e.total === 2)); + assert.ok(pointerEvents.some(e => e.completed === 1)); + assert.ok(pointerEvents.some(e => e.completed === 2)); + }); + }); + + describe('constructor validation', () => { + test('throws if neither authoritativeUrl nor domains provided', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ logLevel: 'silent' }); + }, /Either authoritativeUrl or domains must be provided/); + }); + + test('throws if concurrency is less than 1', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + concurrency: 0, + logLevel: 'silent', + }); + }, /concurrency must be >= 1/); + }); + + test('throws if timeoutMs is less than 1', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + timeoutMs: 0, + logLevel: 'silent', + }); + }, /timeoutMs must be >= 1/); + }); + }); +});