diff --git a/src/squid/access-rules.test.ts b/src/squid/access-rules.test.ts new file mode 100644 index 000000000..4e7bbdfb1 --- /dev/null +++ b/src/squid/access-rules.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for src/squid/access-rules.ts – generateAccessRules. + * + * Covers every branch in generateProtocolRules, generateDenyRule, and + * generateAccessRulesSection: + * + * generateDenyRule + * - both domains + patterns → deny !allowed_domains !allowed_domains_regex + * - only both-protocol plain domains → deny !allowed_domains + * - only both-protocol patterns → deny !allowed_domains_regex + * - only HTTP-only or HTTPS-only (no "both") → deny all + * - completely empty config → deny all + * + * generateProtocolRules + * - HTTP-only plain domains only → !CONNECT allowed_http_only rule + * - HTTP-only patterns only → !CONNECT allowed_http_only_regex rule + * - HTTP-only domains + patterns → both !CONNECT rules + * - HTTPS-only plain domains only → CONNECT allowed_https_only rule + * - HTTPS-only patterns only → CONNECT allowed_https_only_regex rule + * - HTTPS-only domains + patterns → both CONNECT rules + * - no protocol-specific domains → empty array + * + * generateAccessRulesSection + * - blocked rules present → section with header included in output + * - protocol rules present → section with header included in output + * - both absent → empty string + */ +import { generateAccessRules } from './access-rules'; +import { parseDomainConfig } from './domain-acl'; + +describe('generateAccessRules', () => { + // ── denyRule generation ───────────────────────────────────────────────────── + + describe('denyRule', () => { + it('returns "deny all" for completely empty domain config', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny !allowed_domains" when only both-protocol plain domains are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny !allowed_domains'); + }); + + it('returns "deny !allowed_domains_regex" when only both-protocol patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['*.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny !allowed_domains_regex'); + }); + + it('returns "deny !allowed_domains !allowed_domains_regex" when both-protocol domains AND patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', '*.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny !allowed_domains !allowed_domains_regex'); + }); + + it('returns "deny all" when only HTTP-only plain domains are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny all" when only HTTPS-only plain domains are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://secure.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny all" when only HTTP-only patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny all" when only HTTPS-only patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + }); + + // ── HTTP-only protocol rules ──────────────────────────────────────────────── + + describe('HTTP-only protocol rules', () => { + it('generates "allow !CONNECT allowed_http_only" for plain HTTP-only domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only'); + }); + + it('generates "allow !CONNECT allowed_http_only_regex" for HTTP-only patterns', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only_regex'); + }); + + it('generates both !CONNECT rules when HTTP-only domains and patterns coexist', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([ + 'http://metrics.example.com', + 'http://*.metrics2.example.com', + ]); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only'); + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only_regex'); + }); + }); + + // ── HTTPS-only protocol rules ─────────────────────────────────────────────── + + describe('HTTPS-only protocol rules', () => { + it('generates "allow CONNECT allowed_https_only" for plain HTTPS-only domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://secure.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only'); + }); + + it('generates "allow CONNECT allowed_https_only_regex" for HTTPS-only patterns', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only_regex'); + }); + + it('generates both CONNECT rules when HTTPS-only domains and patterns coexist', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([ + 'https://secure.example.com', + 'https://*.secure2.example.com', + ]); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only'); + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only_regex'); + }); + }); + + // ── accessRulesSection structure ──────────────────────────────────────────── + + describe('accessRulesSection', () => { + it('is empty string when there are no protocol-specific rules and no blocked rules', () => { + // Only both-protocol domains → no protocol rules, no blocked rules + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toBe(''); + }); + + it('is empty string for a fully empty config with no blocked rules', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toBe(''); + }); + + it('includes blocked rules section header when blocked access rules are provided', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const blockedAccessRules = ['http_access deny blocked_domains']; + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, blockedAccessRules); + + expect(accessRulesSection).toContain('# Deny requests to blocked domains (blocklist takes precedence)'); + expect(accessRulesSection).toContain('http_access deny blocked_domains'); + }); + + it('includes protocol rules section header when protocol-specific rules are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('# Protocol-specific domain access rules'); + }); + + it('positions blocked rules before protocol-specific rules', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const blockedAccessRules = ['http_access deny blocked_domains']; + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, blockedAccessRules); + + const blockedHeaderPos = accessRulesSection.indexOf('# Deny requests to blocked domains'); + const protocolHeaderPos = accessRulesSection.indexOf('# Protocol-specific domain access rules'); + expect(blockedHeaderPos).toBeLessThan(protocolHeaderPos); + }); + + it('ends with a trailing newline when non-empty', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection.endsWith('\n')).toBe(true); + }); + }); +}); diff --git a/src/squid/acl-generator.test.ts b/src/squid/acl-generator.test.ts new file mode 100644 index 000000000..9571687ef --- /dev/null +++ b/src/squid/acl-generator.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for src/squid/acl-generator.ts – generateAclSections. + * + * Covers every branch in generateDomainAcls and generateBlockedDomainAcls: + * - both-protocol plain domains → allowed_domains ACL + * - both-protocol wildcard patterns → allowed_domains_regex ACL + * - HTTP-only plain domains → allowed_http_only ACL + * - HTTP-only wildcard patterns → allowed_http_only_regex ACL + * - HTTPS-only plain domains → allowed_https_only ACL + * - HTTPS-only wildcard patterns → allowed_https_only_regex ACL + * - Blocked plain domains → blocked_domains ACL + deny rule + * - Blocked wildcard patterns → blocked_domains_regex ACL + deny rule + * - Protocol-prefix / trailing-slash stripping for blocked domains + * - Empty / undefined blocked domains → no blocked config + */ +import { generateAclSections } from './acl-generator'; +import { parseDomainConfig } from './domain-acl'; + +describe('generateAclSections', () => { + // ── Empty config ──────────────────────────────────────────────────────────── + + describe('empty domain config', () => { + it('returns empty aclLines and empty blockedDomainConfig', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const result = generateAclSections(domainsByProto, patternsByProto); + + expect(result.aclLines).toEqual([]); + expect(result.blockedDomainConfig.aclLines).toEqual([]); + expect(result.blockedDomainConfig.accessRules).toEqual([]); + }); + }); + + // ── Both-protocol plain domains ───────────────────────────────────────────── + + describe('both-protocol plain domains', () => { + it('generates allowed_domains ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for allowed domains (HTTP and HTTPS)'); + expect(aclLines.some(l => l.startsWith('acl allowed_domains dstdomain') && l.includes('github.com'))).toBe(true); + }); + + it('generates one ACL entry per domain', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', 'npmjs.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const domainAcls = aclLines.filter(l => l.startsWith('acl allowed_domains dstdomain')); + expect(domainAcls).toHaveLength(2); + }); + + it('uses formatDomainForSquid (leading dot) for domain values', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const aclLine = aclLines.find(l => l.startsWith('acl allowed_domains dstdomain')); + expect(aclLine).toMatch(/^acl allowed_domains dstdomain \.github\.com$/); + }); + }); + + // ── Both-protocol wildcard patterns ───────────────────────────────────────── + + describe('both-protocol wildcard patterns', () => { + it('generates allowed_domains_regex ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['*.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for allowed domain patterns (HTTP and HTTPS)'); + expect(aclLines.some(l => l.startsWith('acl allowed_domains_regex dstdom_regex -i'))).toBe(true); + }); + + it('inserts a blank separator before the regex ACL section when plain domains precede it', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', '*.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const patternHeaderIdx = aclLines.indexOf('# ACL definitions for allowed domain patterns (HTTP and HTTPS)'); + expect(patternHeaderIdx).toBeGreaterThan(0); + expect(aclLines[patternHeaderIdx - 1]).toBe(''); + }); + }); + + // ── HTTP-only plain domains ───────────────────────────────────────────────── + + describe('HTTP-only plain domains', () => { + it('generates allowed_http_only ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTP-only domains'); + expect( + aclLines.some( + l => + l.startsWith('acl allowed_http_only dstdomain') && + /\b\.metrics\.example\.com\b/.test(l) + ) + ).toBe(true); + }); + + it('inserts blank separator before the HTTP-only section', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', 'http://metrics.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const headerIdx = aclLines.indexOf('# ACL definitions for HTTP-only domains'); + expect(headerIdx).toBeGreaterThan(0); + expect(aclLines[headerIdx - 1]).toBe(''); + }); + }); + + // ── HTTP-only wildcard patterns ───────────────────────────────────────────── + + describe('HTTP-only wildcard patterns', () => { + it('generates allowed_http_only_regex ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTP-only domain patterns'); + expect(aclLines.some(l => l.startsWith('acl allowed_http_only_regex dstdom_regex -i'))).toBe(true); + }); + }); + + // ── HTTPS-only plain domains ──────────────────────────────────────────────── + + describe('HTTPS-only plain domains', () => { + it('generates allowed_https_only ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://secure.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTPS-only domains'); + expect( + aclLines.some(l => l.startsWith('acl allowed_https_only dstdomain') && l.includes('secure.example.com')) + ).toBe(true); + }); + + it('inserts blank separator before the HTTPS-only section', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', 'https://secure.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const headerIdx = aclLines.indexOf('# ACL definitions for HTTPS-only domains'); + expect(headerIdx).toBeGreaterThan(0); + expect(aclLines[headerIdx - 1]).toBe(''); + }); + }); + + // ── HTTPS-only wildcard patterns ──────────────────────────────────────────── + + describe('HTTPS-only wildcard patterns', () => { + it('generates allowed_https_only_regex ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTPS-only domain patterns'); + expect(aclLines.some(l => l.startsWith('acl allowed_https_only_regex dstdom_regex -i'))).toBe(true); + }); + }); + + // ── Blocked domains – plain ───────────────────────────────────────────────── + + describe('blocked plain domains', () => { + it('generates blocked_domains ACL and http_access deny rule', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com']); + + expect(blockedDomainConfig.aclLines).toContain('# ACL definitions for blocked domains'); + expect( + blockedDomainConfig.aclLines.some(l => { + if (!l.startsWith('acl blocked_domains dstdomain')) return false; + const tokens = l.trim().split(/\s+/); + return tokens.includes('evil.com'); + }) + ).toBe(true); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); + }); + + it('strips https:// prefix from blocked domains before generating the ACL', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['https://evil.com']); + + const aclLine = blockedDomainConfig.aclLines.find(l => l.split(/\s+/).includes('evil.com')); + expect(aclLine).toBeDefined(); + expect(aclLine).not.toContain('https://'); + }); + + it('strips http:// prefix from blocked domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['http://evil.com']); + + const aclLine = blockedDomainConfig.aclLines.find(l => l.split(/\s+/).includes('evil.com')); + expect(aclLine).toBeDefined(); + expect(aclLine).not.toContain('http://'); + }); + + it('strips trailing slash from blocked domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com/']); + + const aclLine = blockedDomainConfig.aclLines.find(l => l.split(/\s+/).includes('evil.com')); + expect(aclLine).toBeDefined(); + expect(aclLine).not.toContain('/'); + }); + + it('works on a stand-alone allowlist (no allowed domains)', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com']); + + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); + }); + }); + + // ── Blocked domains – wildcard patterns ──────────────────────────────────── + + describe('blocked wildcard patterns', () => { + it('generates blocked_domains_regex ACL and http_access deny rule', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['*.evil.com']); + + expect(blockedDomainConfig.aclLines).toContain('# ACL definitions for blocked domain patterns (wildcard)'); + expect(blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains_regex dstdom_regex -i'))).toBe(true); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains_regex'); + }); + }); + + // ── Blocked domains – mixed plain + wildcard ──────────────────────────────── + + describe('blocked mixed plain and wildcard', () => { + it('generates both blocked_domains and blocked_domains_regex ACLs', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, [ + 'evil.com', + '*.malware.net', + ]); + + expect(blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains dstdomain'))).toBe(true); + expect(blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains_regex dstdom_regex -i'))).toBe(true); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains_regex'); + }); + }); + + // ── Empty / undefined blocked domains ────────────────────────────────────── + + describe('no blocked domains', () => { + it('returns empty blocked config when blockedDomains is an empty array', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, []); + + expect(blockedDomainConfig.aclLines).toEqual([]); + expect(blockedDomainConfig.accessRules).toEqual([]); + }); + + it('returns empty blocked config when blockedDomains is undefined', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, undefined); + + expect(blockedDomainConfig.aclLines).toEqual([]); + expect(blockedDomainConfig.accessRules).toEqual([]); + }); + }); +}); diff --git a/src/squid/domain-acl.test.ts b/src/squid/domain-acl.test.ts new file mode 100644 index 000000000..01123b683 --- /dev/null +++ b/src/squid/domain-acl.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for src/squid/domain-acl.ts. + * + * Covers all public functions: + * assertSafeForSquidConfig – injection-prevention defence-in-depth + * formatDomainForSquid – leading-dot canonicalisation + * parseDomainConfig – domain parsing, grouping, deduplication + */ +import { assertSafeForSquidConfig, formatDomainForSquid, parseDomainConfig } from './domain-acl'; + +// ── assertSafeForSquidConfig ──────────────────────────────────────────────── + +describe('assertSafeForSquidConfig', () => { + it('returns safe domain strings unchanged', () => { + expect(assertSafeForSquidConfig('github.com')).toBe('github.com'); + expect(assertSafeForSquidConfig('.github.com')).toBe('.github.com'); + expect(assertSafeForSquidConfig('api.internal-service.example.com')).toBe('api.internal-service.example.com'); + }); + + it('throws for strings containing whitespace', () => { + expect(() => assertSafeForSquidConfig('github.com evil.com')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com\tevil.com')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com\nevil.com')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a double quote', () => { + expect(() => assertSafeForSquidConfig('"github.com"')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a single quote', () => { + expect(() => assertSafeForSquidConfig("github.com'")).toThrow(/SECURITY/); + }); + + it('throws for strings containing a semicolon', () => { + expect(() => assertSafeForSquidConfig('github.com;evil')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a backtick', () => { + expect(() => assertSafeForSquidConfig('github.com`evil`')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a hash / comment character', () => { + expect(() => assertSafeForSquidConfig('github.com#evil')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a null byte', () => { + expect(() => assertSafeForSquidConfig('github.com\x00evil')).toThrow(/SECURITY/); + }); +}); + +// ── formatDomainForSquid ─────────────────────────────────────────────────── + +describe('formatDomainForSquid', () => { + it('prepends a dot to a plain domain', () => { + expect(formatDomainForSquid('github.com')).toBe('.github.com'); + }); + + it('leaves a domain that already starts with a dot unchanged', () => { + expect(formatDomainForSquid('.github.com')).toBe('.github.com'); + }); + + it('handles multi-level subdomains', () => { + expect(formatDomainForSquid('api.internal.example.com')).toBe('.api.internal.example.com'); + }); + + it('throws for domains containing dangerous characters', () => { + expect(() => formatDomainForSquid('evil.com;http_access allow all')).toThrow(/SECURITY/); + }); +}); + +// ── parseDomainConfig ────────────────────────────────────────────────────── + +describe('parseDomainConfig', () => { + describe('empty input', () => { + it('returns empty domain groups for an empty array', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + + expect(domainsByProto.both).toEqual([]); + expect(domainsByProto.http).toEqual([]); + expect(domainsByProto.https).toEqual([]); + expect(patternsByProto.both).toEqual([]); + expect(patternsByProto.http).toEqual([]); + expect(patternsByProto.https).toEqual([]); + }); + }); + + describe('protocol grouping', () => { + it('places a plain domain with no protocol prefix in domainsByProto.both', () => { + const { domainsByProto } = parseDomainConfig(['github.com']); + expect(domainsByProto.both).toContain('github.com'); + expect(domainsByProto.http).toHaveLength(0); + expect(domainsByProto.https).toHaveLength(0); + }); + + it('places an http:// prefixed domain in domainsByProto.http', () => { + const { domainsByProto } = parseDomainConfig(['http://metrics.internal.com']); + expect(domainsByProto.http).toContain('metrics.internal.com'); + expect(domainsByProto.both).toHaveLength(0); + expect(domainsByProto.https).toHaveLength(0); + }); + + it('places an https:// prefixed domain in domainsByProto.https', () => { + const { domainsByProto } = parseDomainConfig(['https://secure.example.com']); + expect(domainsByProto.https).toContain('secure.example.com'); + expect(domainsByProto.both).toHaveLength(0); + expect(domainsByProto.http).toHaveLength(0); + }); + }); + + describe('wildcard patterns', () => { + it('places a wildcard domain in patternsByProto.both', () => { + const { patternsByProto, domainsByProto } = parseDomainConfig(['*.example.com']); + expect(patternsByProto.both).toHaveLength(1); + expect(domainsByProto.both).toHaveLength(0); + expect(patternsByProto.both[0]).toHaveProperty('regex'); + }); + + it('places an http:// wildcard in patternsByProto.http', () => { + const { patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + expect(patternsByProto.http).toHaveLength(1); + expect(patternsByProto.http[0]).toHaveProperty('regex'); + }); + + it('places an https:// wildcard in patternsByProto.https', () => { + const { patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + expect(patternsByProto.https).toHaveLength(1); + expect(patternsByProto.https[0]).toHaveProperty('regex'); + }); + }); + + describe('subdomain deduplication', () => { + it('removes a subdomain when its parent domain is also in the list', () => { + const { domainsByProto } = parseDomainConfig(['api.github.com', 'github.com']); + // api.github.com is redundant: github.com already covers it + expect(domainsByProto.both).not.toContain('api.github.com'); + expect(domainsByProto.both).toContain('github.com'); + }); + + it('keeps both domains when they share no parent-child relationship', () => { + const { domainsByProto } = parseDomainConfig(['github.com', 'npmjs.com']); + expect(domainsByProto.both).toContain('github.com'); + expect(domainsByProto.both).toContain('npmjs.com'); + }); + }); + + describe('wildcard pattern coverage deduplication', () => { + it('removes a plain subdomain already matched by a wildcard pattern', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['api.github.com', '*.github.com']); + // api.github.com is covered by *.github.com — should be filtered out + expect(domainsByProto.both).not.toContain('api.github.com'); + expect(patternsByProto.both).toHaveLength(1); + }); + }); + + describe('mixed protocol domains', () => { + it('correctly groups multiple domains with different protocols', () => { + const { domainsByProto } = parseDomainConfig([ + 'github.com', + 'http://metrics.internal.com', + 'https://secure.example.com', + ]); + expect(domainsByProto.both).toContain('github.com'); + expect(domainsByProto.http).toContain('metrics.internal.com'); + expect(domainsByProto.https).toContain('secure.example.com'); + }); + }); +});