UNPKG

a11yanalyze

Version:

A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance

643 lines (563 loc) 18.1 kB
/** * Comprehensive URL Validation and Error Handling System * Provides robust input validation with helpful error messages and auto-correction suggestions */ import { URL } from 'url'; import { ErrorLogger } from '../output/error-logger'; /** * URL validation result */ export interface UrlValidationResult { /** Whether the URL is valid */ isValid: boolean; /** The validated and normalized URL (if valid) */ normalizedUrl?: string; /** Validation error details */ error?: UrlValidationError; /** Auto-correction suggestions */ suggestions?: string[]; /** Warnings about the URL (even if valid) */ warnings?: string[]; } /** * URL validation error details */ export interface UrlValidationError { /** Error type category */ type: 'format' | 'protocol' | 'domain' | 'network' | 'security' | 'accessibility'; /** Human-readable error message */ message: string; /** Technical error details */ details?: string; /** Original input that caused the error */ input: string; /** Suggested fixes */ fixes?: string[]; } /** * URL validation options */ export interface UrlValidationOptions { /** Allowed protocols */ allowedProtocols?: string[]; /** Whether to allow localhost/local URLs */ allowLocalhost?: boolean; /** Whether to allow IP addresses */ allowIpAddresses?: boolean; /** Whether to check for accessibility-hostile patterns */ checkAccessibilityHostile?: boolean; /** Whether to perform basic network connectivity check */ checkConnectivity?: boolean; /** Whether to allow non-standard ports */ allowNonStandardPorts?: boolean; /** Whether to normalize URLs (add protocol, www, etc.) */ autoNormalize?: boolean; /** Maximum URL length */ maxLength?: number; } /** * Comprehensive URL Validator */ export class UrlValidator { private static readonly DEFAULT_OPTIONS: Required<UrlValidationOptions> = { allowedProtocols: ['http', 'https'], allowLocalhost: true, allowIpAddresses: true, checkAccessibilityHostile: true, checkConnectivity: false, allowNonStandardPorts: true, autoNormalize: true, maxLength: 2083, // IE limit, widely accepted }; private static readonly COMMON_TYPOS = new Map([ ['htp://', 'http://'], ['htps://', 'https://'], ['http//', 'http://'], ['https//', 'https://'], ['www.', 'https://www.'], ['.com/', '.com'], ['.org/', '.org'], ['.net/', '.net'], ['localhost:', 'http://localhost:'], ]); private static readonly ACCESSIBILITY_HOSTILE_PATTERNS = [ /\?.*utm_/i, // UTM tracking parameters /\?.*fbclid/i, // Facebook click tracking /\?.*gclid/i, // Google click tracking /#.*:~:text=/i, // Text fragments that might not be accessible ]; private static readonly SUSPICIOUS_DOMAINS = [ 'bit.ly', 'tinyurl.com', 'goo.gl', 't.co', // URL shorteners 'localhost.localdomain', // Common test domain issues ]; private options: Required<UrlValidationOptions>; private errorLogger: ErrorLogger; constructor(options: Partial<UrlValidationOptions> = {}) { this.options = { ...UrlValidator.DEFAULT_OPTIONS, ...options }; this.errorLogger = new ErrorLogger(); } /** * Validate a URL with comprehensive checking */ async validate(input: string): Promise<UrlValidationResult> { try { // Normalize input const normalizedInput = this.normalizeInput(input); // Basic format validation const formatResult = this.validateFormat(normalizedInput); if (!formatResult.isValid) { return formatResult; } const url = new URL(formatResult.normalizedUrl!); // Protocol validation const protocolResult = this.validateProtocol(url); if (!protocolResult.isValid) { return protocolResult; } // Domain validation const domainResult = this.validateDomain(url); if (!domainResult.isValid) { return domainResult; } // Security validation const securityResult = this.validateSecurity(url); if (!securityResult.isValid) { return securityResult; } // Accessibility validation const accessibilityResult = this.validateAccessibility(url); // Collect warnings const warnings: string[] = []; warnings.push(...(securityResult.warnings || [])); warnings.push(...(accessibilityResult.warnings || [])); // Network connectivity check (if enabled) if (this.options.checkConnectivity) { try { await this.checkConnectivity(url); } catch (error) { warnings.push(`Network connectivity check failed: ${error instanceof Error ? error.message : String(error)}`); } } return { isValid: true, normalizedUrl: url.toString(), warnings: warnings.length > 0 ? warnings : undefined, }; } catch (error) { this.errorLogger.error('URL validation failed', 'validation', error as Error, 'url-validator'); return { isValid: false, error: { type: 'format', message: 'Failed to parse URL', details: error instanceof Error ? error.message : String(error), input, fixes: this.generateSuggestions(input), }, suggestions: this.generateSuggestions(input), }; } } /** * Validate multiple URLs */ async validateMultiple(inputs: string[]): Promise<UrlValidationResult[]> { const results = await Promise.allSettled( inputs.map(input => this.validate(input)) ); return results.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } else { const input = inputs[index] || ''; return { isValid: false, error: { type: 'format', message: 'Validation failed', details: result.reason instanceof Error ? result.reason.message : String(result.reason), input, fixes: this.generateSuggestions(input), }, suggestions: this.generateSuggestions(input), }; } }); } /** * Quick validation for common use cases */ isValidUrl(input: string): boolean { try { const normalized = this.normalizeInput(input); const url = new URL(normalized); return this.options.allowedProtocols.includes(url.protocol.replace(':', '')); } catch { return false; } } /** * Normalize input to handle common formatting issues * @private */ private normalizeInput(input: string): string { if (!input || typeof input !== 'string') { throw new Error('Input must be a non-empty string'); } let normalized = input.trim(); // Check for common typos for (const [typo, correction] of UrlValidator.COMMON_TYPOS) { if (normalized.startsWith(typo)) { normalized = normalized.replace(typo, correction); break; } } // Auto-add protocol if missing if (this.options.autoNormalize && !normalized.match(/^[a-z][a-z0-9+.-]*:/i)) { // Special handling for localhost if (normalized.startsWith('localhost')) { normalized = `http://${normalized}`; } else { normalized = `https://${normalized}`; } } return normalized; } /** * Validate URL format * @private */ private validateFormat(input: string): UrlValidationResult { // Length check if (input.length > this.options.maxLength) { return { isValid: false, error: { type: 'format', message: `URL is too long (${input.length} characters, maximum ${this.options.maxLength})`, input, fixes: ['Consider using a URL shortener', 'Remove unnecessary query parameters'], }, }; } // Basic URL pattern check if (!input.match(/^[a-z][a-z0-9+.-]*:\/\//i)) { return { isValid: false, error: { type: 'format', message: 'URL must include a valid protocol (http:// or https://)', input, fixes: this.generateSuggestions(input), }, suggestions: this.generateSuggestions(input), }; } try { new URL(input); return { isValid: true, normalizedUrl: input }; } catch (error) { return { isValid: false, error: { type: 'format', message: 'Invalid URL format', details: error instanceof Error ? error.message : String(error), input, fixes: this.generateSuggestions(input), }, suggestions: this.generateSuggestions(input), }; } } /** * Validate URL protocol * @private */ private validateProtocol(url: URL): UrlValidationResult { const protocol = url.protocol.replace(':', ''); if (!this.options.allowedProtocols.includes(protocol)) { return { isValid: false, error: { type: 'protocol', message: `Protocol "${protocol}" is not allowed`, details: `Allowed protocols: ${this.options.allowedProtocols.join(', ')}`, input: url.toString(), fixes: [ `Change to https://${url.host}${url.pathname}${url.search}${url.hash}`, `Change to http://${url.host}${url.pathname}${url.search}${url.hash}`, ], }, }; } return { isValid: true }; } /** * Validate domain and hostname * @private */ private validateDomain(url: URL): UrlValidationResult { const hostname = url.hostname.toLowerCase(); const warnings: string[] = []; // Localhost check if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { if (!this.options.allowLocalhost) { return { isValid: false, error: { type: 'domain', message: 'Localhost URLs are not allowed', input: url.toString(), fixes: ['Use a public domain name instead of localhost'], }, }; } warnings.push('Using localhost - this URL will only work on your local machine'); } // IP address check if (this.isIpAddress(hostname)) { if (!this.options.allowIpAddresses) { return { isValid: false, error: { type: 'domain', message: 'IP addresses are not allowed', input: url.toString(), fixes: ['Use a domain name instead of an IP address'], }, }; } warnings.push('Using IP address - consider using a domain name for better accessibility'); } // Port validation if (url.port && !this.options.allowNonStandardPorts) { const port = parseInt(url.port, 10); const isStandardPort = (url.protocol === 'http:' && port === 80) || (url.protocol === 'https:' && port === 443); if (!isStandardPort) { return { isValid: false, error: { type: 'domain', message: `Non-standard port ${port} is not allowed`, input: url.toString(), fixes: ['Remove the port number', 'Use standard ports (80 for HTTP, 443 for HTTPS)'], }, }; } } // Suspicious domain check if (UrlValidator.SUSPICIOUS_DOMAINS.includes(hostname)) { warnings.push(`Domain "${hostname}" may be a URL shortener or testing domain`); } // Basic domain format validation if (!hostname.includes('.') && hostname !== 'localhost') { return { isValid: false, error: { type: 'domain', message: 'Domain name appears to be invalid (missing TLD)', input: url.toString(), fixes: [`Add a top-level domain like "${hostname}.com"`], }, }; } return { isValid: true, warnings }; } /** * Validate security aspects * @private */ private validateSecurity(url: URL): UrlValidationResult { const warnings: string[] = []; // HTTPS recommendation if (url.protocol === 'http:' && url.hostname !== 'localhost') { warnings.push('Consider using HTTPS for better security (recommended for accessibility testing)'); } // Check for potentially dangerous URLs if (url.pathname.includes('..')) { return { isValid: false, error: { type: 'security', message: 'URL contains potentially dangerous path traversal patterns', input: url.toString(), fixes: ['Remove ".." from the URL path'], }, }; } return { isValid: true, warnings }; } /** * Validate accessibility-related concerns * @private */ private validateAccessibility(url: URL): UrlValidationResult { const warnings: string[] = []; if (this.options.checkAccessibilityHostile) { // Check for tracking parameters that might interfere with accessibility for (const pattern of UrlValidator.ACCESSIBILITY_HOSTILE_PATTERNS) { if (pattern.test(url.toString())) { warnings.push('URL contains tracking parameters that might affect accessibility testing accuracy'); break; } } // Check for text fragments if (url.hash.includes(':~:text=')) { warnings.push('URL contains text fragments - ensure these work with screen readers'); } // Check for very long URLs that might be problematic if (url.toString().length > 1000) { warnings.push('Very long URLs may cause issues with some accessibility tools'); } } return { isValid: true, warnings }; } /** * Check network connectivity (basic) * @private */ private async checkConnectivity(url: URL): Promise<void> { // This is a simplified connectivity check // In a full implementation, you might use a library like node-fetch // For now, we'll just validate that we can construct a proper request if (url.hostname === 'localhost' || this.isIpAddress(url.hostname)) { // Skip connectivity check for local URLs return; } // Placeholder for actual network check // In practice, you'd make a HEAD request or similar return Promise.resolve(); } /** * Check if hostname is an IP address * @private */ private isIpAddress(hostname: string): boolean { // IPv4 check if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) { return hostname.split('.').every(octet => { const num = parseInt(octet, 10); return num >= 0 && num <= 255; }); } // IPv6 check (simplified) if (hostname.includes(':') && hostname.length > 2) { return /^[0-9a-f:]+$/i.test(hostname); } return false; } /** * Generate auto-correction suggestions * @private */ private generateSuggestions(input: string): string[] { const suggestions: string[] = []; // Check for common typos for (const [typo, correction] of UrlValidator.COMMON_TYPOS) { if (input.includes(typo)) { suggestions.push(input.replace(typo, correction)); } } // Add protocol if missing if (!input.match(/^[a-z][a-z0-9+.-]*:/i)) { suggestions.push(`https://${input}`); suggestions.push(`http://${input}`); } // Add www if it looks like a domain if (!input.includes('://') && input.includes('.') && !input.startsWith('www.')) { suggestions.push(`https://www.${input}`); } // Remove duplicates return [...new Set(suggestions)]; } /** * Create validator with preset configurations */ static strict(): UrlValidator { return new UrlValidator({ allowedProtocols: ['https'], allowLocalhost: false, allowIpAddresses: false, checkAccessibilityHostile: true, allowNonStandardPorts: false, }); } static lenient(): UrlValidator { return new UrlValidator({ allowedProtocols: ['http', 'https', 'ftp'], allowLocalhost: true, allowIpAddresses: true, checkAccessibilityHostile: false, allowNonStandardPorts: true, }); } static development(): UrlValidator { return new UrlValidator({ allowedProtocols: ['http', 'https'], allowLocalhost: true, allowIpAddresses: true, checkAccessibilityHostile: false, allowNonStandardPorts: true, checkConnectivity: false, }); } } /** * URL validation error formatter for CLI display */ export class UrlErrorFormatter { /** * Format validation error for console display */ static formatError(result: UrlValidationResult): string { if (result.isValid) { return ''; } const error = result.error!; let output = `❌ ${error.message}\n`; if (error.details) { output += ` Details: ${error.details}\n`; } if (error.fixes && error.fixes.length > 0) { output += ` 💡 Suggestions:\n`; error.fixes.forEach((fix, index) => { output += ` ${index + 1}. ${fix}\n`; }); } if (result.suggestions && result.suggestions.length > 0) { output += ` 🔧 Try these URLs:\n`; result.suggestions.slice(0, 3).forEach((suggestion, index) => { output += ` ${index + 1}. ${suggestion}\n`; }); } return output; } /** * Format validation warnings for console display */ static formatWarnings(result: UrlValidationResult): string { if (!result.warnings || result.warnings.length === 0) { return ''; } let output = `⚠️ Warnings:\n`; result.warnings.forEach((warning, index) => { output += ` ${index + 1}. ${warning}\n`; }); return output; } /** * Format validation success for console display */ static formatSuccess(result: UrlValidationResult): string { if (!result.isValid) { return ''; } let output = `✅ URL is valid: ${result.normalizedUrl}\n`; if (result.warnings && result.warnings.length > 0) { output += this.formatWarnings(result); } return output; } }