UNPKG

dnsweeper

Version:

Advanced CLI tool for DNS record risk analysis and cleanup. Features CSV import for Cloudflare/Route53, automated risk assessment, and parallel DNS validation.

414 lines 20.7 kB
/** * cname-chain.ts のユニットテスト */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as dns } from 'node:dns'; import { traceCnameChain, validateCnameChain, getCnameChainStats, traceMultipleCnameChains } from '../../src/utils/cname-chain.js'; // DNS モジュールをモック vi.mock('node:dns', () => ({ promises: { resolveCname: vi.fn() } })); const mockResolveCname = vi.mocked(dns.resolveCname); describe('cname-chain', () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('traceCnameChain', () => { it('単純なCNAMEチェーンを追跡', async () => { // www.example.com -> example.com -> (終了) mockResolveCname .mockResolvedValueOnce(['example.com']) .mockRejectedValueOnce({ code: 'ENODATA' }); const result = await traceCnameChain('www.example.com'); expect(result.chain).toEqual(['www.example.com', 'example.com']); expect(result.finalTarget).toBe('example.com'); expect(result.hasLoop).toBe(false); expect(result.maxDepthReached).toBe(false); expect(result.resolutionTime).toBeGreaterThan(0); }); it('複数段のCNAMEチェーンを追跡', async () => { // alias.example.com -> www.example.com -> example.com -> (終了) mockResolveCname .mockResolvedValueOnce(['www.example.com']) .mockResolvedValueOnce(['example.com']) .mockRejectedValueOnce({ code: 'ENODATA' }); const result = await traceCnameChain('alias.example.com'); expect(result.chain).toEqual(['alias.example.com', 'www.example.com', 'example.com']); expect(result.finalTarget).toBe('example.com'); expect(result.hasLoop).toBe(false); expect(result.maxDepthReached).toBe(false); }); it('CNAMEループを検知', async () => { // a.example.com -> b.example.com -> a.example.com (ループ) mockResolveCname .mockResolvedValueOnce(['b.example.com']) .mockResolvedValueOnce(['a.example.com']); const result = await traceCnameChain('a.example.com'); expect(result.chain).toEqual(['a.example.com', 'b.example.com']); expect(result.hasLoop).toBe(true); expect(result.finalTarget).toBeNull(); }); it('最大深度に到達', async () => { // 無限チェーンの模擬 mockResolveCname.mockImplementation((domain) => { const num = parseInt(domain.split('.')[0].replace('level', '')) || 0; return Promise.resolve([`level${num + 1}.example.com`]); }); const result = await traceCnameChain('level1.example.com', { maxDepth: 5 }); expect(result.chain).toHaveLength(5); expect(result.maxDepthReached).toBe(true); expect(result.hasLoop).toBe(false); expect(result.finalTarget).toBe('level5.example.com'); }); it('タイムアウトを適切に処理', async () => { // 永続的に解決しないPromise mockResolveCname.mockImplementation(() => new Promise(() => { })); const result = await traceCnameChain('timeout.example.com', { timeout: 100 }); expect(result.chain).toEqual(['timeout.example.com']); expect(result.hasLoop).toBe(false); expect(result.maxDepthReached).toBe(false); }); it('DNS解決エラーを適切に処理', async () => { mockResolveCname.mockRejectedValueOnce({ code: 'ENOTFOUND' }); const result = await traceCnameChain('notfound.example.com'); expect(result.chain).toEqual(['notfound.example.com']); expect(result.finalTarget).toBe('notfound.example.com'); expect(result.hasLoop).toBe(false); }); it('空のCNAME応答を処理', async () => { mockResolveCname.mockResolvedValueOnce([]); const result = await traceCnameChain('empty.example.com'); expect(result.chain).toEqual(['empty.example.com']); expect(result.finalTarget).toBe('empty.example.com'); expect(result.hasLoop).toBe(false); }); it('followToEndオプションの動作', async () => { mockResolveCname.mockResolvedValueOnce(['target.example.com']); const result = await traceCnameChain('source.example.com', { followToEnd: false }); expect(result.chain).toEqual(['source.example.com']); expect(result.finalTarget).toBe('target.example.com'); }); it('複数のCNAME結果の最初を使用', async () => { mockResolveCname .mockResolvedValueOnce(['first.example.com', 'second.example.com']) .mockRejectedValueOnce({ code: 'ENODATA' }); const result = await traceCnameChain('multi.example.com'); expect(result.chain).toEqual(['multi.example.com', 'first.example.com']); expect(result.finalTarget).toBe('first.example.com'); }); it('大文字小文字を正規化', async () => { mockResolveCname .mockResolvedValueOnce(['TARGET.EXAMPLE.COM']) .mockRejectedValueOnce({ code: 'ENODATA' }); const result = await traceCnameChain('SOURCE.EXAMPLE.COM'); expect(result.chain).toEqual(['source.example.com', 'target.example.com']); expect(result.finalTarget).toBe('target.example.com'); }); }); describe('validateCnameChain', () => { it('正常なチェーンを検証', () => { const validChain = { chain: ['www.example.com', 'example.com'], finalTarget: 'example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 100 }; const validation = validateCnameChain(validChain); expect(validation.isValid).toBe(true); expect(validation.issues).toHaveLength(0); expect(validation.recommendations).toHaveLength(0); }); it('ループを含むチェーンを検証', () => { const loopChain = { chain: ['a.example.com', 'b.example.com'], finalTarget: null, hasLoop: true, maxDepthReached: false, resolutionTime: 100 }; const validation = validateCnameChain(loopChain); expect(validation.isValid).toBe(false); expect(validation.issues).toContain('CNAMEチェーンにループが検出されました'); expect(validation.recommendations).toContain('DNSレコードの設定を確認し、循環参照を修正してください'); }); it('最大深度に到達したチェーンを検証', () => { const maxDepthChain = { chain: Array.from({ length: 10 }, (_, i) => `level${i}.example.com`), finalTarget: 'level9.example.com', hasLoop: false, maxDepthReached: true, resolutionTime: 100 }; const validation = validateCnameChain(maxDepthChain); expect(validation.isValid).toBe(false); expect(validation.issues).toContain('CNAMEチェーンが最大深度に達しました'); expect(validation.recommendations).toContain('不必要に長いCNAMEチェーンを短縮することを検討してください'); }); it('長いチェーンに警告', () => { const longChain = { chain: Array.from({ length: 7 }, (_, i) => `level${i}.example.com`), finalTarget: 'level6.example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 100 }; const validation = validateCnameChain(longChain); expect(validation.isValid).toBe(false); expect(validation.issues).toContain('CNAMEチェーンが長すぎます(7段階)'); expect(validation.recommendations).toContain('パフォーマンスのため、CNAMEチェーンを5段階以下に短縮することをお勧めします'); }); it('解決時間が長い場合に警告', () => { const slowChain = { chain: ['www.example.com', 'example.com'], finalTarget: 'example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 3000 // 3秒 }; const validation = validateCnameChain(slowChain); expect(validation.isValid).toBe(false); expect(validation.issues).toContain('CNAME解決に時間がかかりすぎています(3000ms)'); expect(validation.recommendations).toContain('DNSサーバーの応答性能を確認してください'); }); it('最終ターゲットが解決できない場合', () => { const unresolved = { chain: ['www.example.com'], finalTarget: null, hasLoop: false, maxDepthReached: false, resolutionTime: 100 }; const validation = validateCnameChain(unresolved); expect(validation.isValid).toBe(false); expect(validation.issues).toContain('CNAMEチェーンの最終ターゲットが解決できませんでした'); expect(validation.recommendations).toContain('DNSレコードの設定を確認してください'); }); it('複数の問題を同時に検出', () => { const problematicChain = { chain: Array.from({ length: 8 }, (_, i) => `level${i}.example.com`), finalTarget: null, hasLoop: true, maxDepthReached: true, resolutionTime: 2500 }; const validation = validateCnameChain(problematicChain); expect(validation.isValid).toBe(false); expect(validation.issues.length).toBeGreaterThan(1); expect(validation.recommendations.length).toBeGreaterThan(1); }); }); describe('getCnameChainStats', () => { it('空の結果リストの統計', () => { const stats = getCnameChainStats([]); expect(stats.totalChains).toBe(0); expect(stats.averageDepth).toBe(0); expect(stats.maxDepth).toBe(0); expect(stats.loopCount).toBe(0); expect(stats.averageResolutionTime).toBe(0); expect(stats.healthScore).toBe(100); }); it('複数チェーンの統計を計算', () => { const results = [ { chain: ['a.example.com', 'b.example.com'], finalTarget: 'b.example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 100 }, { chain: ['c.example.com', 'd.example.com', 'e.example.com'], finalTarget: 'e.example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 200 }, { chain: ['f.example.com', 'g.example.com'], finalTarget: null, hasLoop: true, maxDepthReached: false, resolutionTime: 150 } ]; const stats = getCnameChainStats(results); expect(stats.totalChains).toBe(3); expect(stats.averageDepth).toBe((2 + 3 + 2) / 3); expect(stats.maxDepth).toBe(3); expect(stats.loopCount).toBe(1); expect(stats.averageResolutionTime).toBe((100 + 200 + 150) / 3); expect(stats.healthScore).toBeLessThan(100); // ループがあるため }); it('ヘルススコアの計算', () => { // 全て正常なチェーン const healthyResults = [ { chain: ['a.example.com', 'b.example.com'], finalTarget: 'b.example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 100 }, { chain: ['c.example.com', 'd.example.com'], finalTarget: 'd.example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 150 } ]; const healthyStats = getCnameChainStats(healthyResults); expect(healthyStats.healthScore).toBe(100); // 問題のあるチェーンを含む const problematicResults = [ ...healthyResults, { chain: ['e.example.com', 'f.example.com'], finalTarget: null, hasLoop: true, maxDepthReached: false, resolutionTime: 100 } ]; const problematicStats = getCnameChainStats(problematicResults); expect(problematicStats.healthScore).toBeLessThan(100); }); it('長いチェーンがヘルススコアに影響', () => { const longChainResults = [ { chain: Array.from({ length: 8 }, (_, i) => `level${i}.example.com`), finalTarget: 'level7.example.com', hasLoop: false, maxDepthReached: false, resolutionTime: 100 } ]; const stats = getCnameChainStats(longChainResults); expect(stats.healthScore).toBeLessThan(100); }); it('最大深度到達がヘルススコアに影響', () => { const maxDepthResults = [ { chain: Array.from({ length: 10 }, (_, i) => `level${i}.example.com`), finalTarget: 'level9.example.com', hasLoop: false, maxDepthReached: true, resolutionTime: 100 } ]; const stats = getCnameChainStats(maxDepthResults); expect(stats.healthScore).toBeLessThan(100); }); }); describe('traceMultipleCnameChains', () => { it('複数ドメインを並列で追跡', async () => { const domains = ['www.example.com', 'api.example.com']; mockResolveCname .mockResolvedValueOnce(['example.com']) // www.example.com .mockRejectedValueOnce({ code: 'ENODATA' }) // example.com (終了) .mockResolvedValueOnce(['internal.example.com']) // api.example.com .mockRejectedValueOnce({ code: 'ENODATA' }); // internal.example.com (終了) const results = await traceMultipleCnameChains(domains, { concurrency: 2 }); expect(results.size).toBe(2); expect(results.has('www.example.com')).toBe(true); expect(results.has('api.example.com')).toBe(true); const wwwResult = results.get('www.example.com'); expect(wwwResult.chain).toEqual(['www.example.com', 'example.com']); expect(wwwResult.finalTarget).toBe('example.com'); const apiResult = results.get('api.example.com'); expect(apiResult.chain).toEqual(['api.example.com', 'internal.example.com']); expect(apiResult.finalTarget).toBe('internal.example.com'); }); it('エラーが発生したドメインも結果に含める', async () => { const domains = ['error.example.com']; mockResolveCname.mockRejectedValueOnce(new Error('Network error')); const results = await traceMultipleCnameChains(domains); expect(results.size).toBe(1); expect(results.has('error.example.com')).toBe(true); const errorResult = results.get('error.example.com'); expect(errorResult.chain).toEqual(['error.example.com']); expect(errorResult.finalTarget).toBeNull(); }); it('並列数制限が動作', async () => { const domains = ['a.example.com', 'b.example.com', 'c.example.com']; let concurrentCalls = 0; let maxConcurrent = 0; mockResolveCname.mockImplementation(async () => { concurrentCalls++; maxConcurrent = Math.max(maxConcurrent, concurrentCalls); await new Promise(resolve => setTimeout(resolve, 50)); concurrentCalls--; throw { code: 'ENODATA' }; }); await traceMultipleCnameChains(domains, { concurrency: 2 }); expect(maxConcurrent).toBeLessThanOrEqual(2); }); it('空のドメインリストを処理', async () => { const results = await traceMultipleCnameChains([]); expect(results.size).toBe(0); }); it('オプションがチェーン追跡に正しく渡される', async () => { const domains = ['test.example.com']; mockResolveCname.mockRejectedValueOnce({ code: 'ENODATA' }); const results = await traceMultipleCnameChains(domains, { maxDepth: 5, timeout: 1000, followToEnd: false }); expect(results.size).toBe(1); // オプションが正しく渡されることの間接的な確認 // (実際の動作は traceCnameChain の単体テストで確認済み) }); }); describe('エラーハンドリング', () => { it('DNS解決の各種エラーコードを処理', async () => { const errorCodes = ['ENOTFOUND', 'ENODATA', 'ESERVFAIL', 'ETIMEOUT']; for (const code of errorCodes) { mockResolveCname.mockRejectedValueOnce({ code }); const result = await traceCnameChain(`${code.toLowerCase()}.example.com`); expect(result.chain).toEqual([`${code.toLowerCase()}.example.com`]); expect(result.finalTarget).toBe(`${code.toLowerCase()}.example.com`); expect(result.hasLoop).toBe(false); } }); it('予期しないエラーを適切に処理', async () => { const unexpectedError = new Error('Unexpected error'); mockResolveCname.mockRejectedValueOnce(unexpectedError); const result = await traceCnameChain('unexpected.example.com'); expect(result.chain).toEqual(['unexpected.example.com']); // エラーが発生してもクラッシュしないことを確認 }); it('無効な入力でもエラーにならない', async () => { await expect(traceCnameChain('')).resolves.toBeDefined(); await expect(traceCnameChain(' ')).resolves.toBeDefined(); }); }); describe('パフォーマンス', () => { it('大量のドメインを効率的に処理', async () => { const domains = Array.from({ length: 100 }, (_, i) => `domain${i}.example.com`); mockResolveCname.mockImplementation(async () => { throw { code: 'ENODATA' }; }); const start = Date.now(); const results = await traceMultipleCnameChains(domains, { concurrency: 10 }); const duration = Date.now() - start; expect(results.size).toBe(100); expect(duration).toBeLessThan(5000); // 5秒以内 }); it('タイムアウト設定が適切に機能', async () => { mockResolveCname.mockImplementation(() => new Promise(() => { })); // 永続的に待機 const start = Date.now(); const result = await traceCnameChain('timeout-test.example.com', { timeout: 100 }); const duration = Date.now() - start; expect(duration).toBeLessThan(200); // タイムアウト + バッファ expect(result.chain).toEqual(['timeout-test.example.com']); }); }); }); //# sourceMappingURL=cname-chain.test.js.map