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.

838 lines (716 loc) 23.9 kB
/** * cloudflare.ts のユニットテスト */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CloudflareClient, type CloudflareConfig, type CloudflareZone, type CloudflareDNSRecord, type CloudflareResponse } from '../../src/lib/api/cloudflare.js'; import type { ICSVRecord } from '../../src/types/index.js'; // fetch をモック const mockFetch = vi.fn(); global.fetch = mockFetch; describe('CloudflareClient', () => { let client: CloudflareClient; let config: CloudflareConfig; beforeEach(() => { config = { apiToken: 'test-api-token', email: 'test@example.com', apiKey: 'test-api-key' }; client = new CloudflareClient(config); // fetch をリセット mockFetch.mockReset(); }); afterEach(() => { vi.clearAllMocks(); }); describe('constructor', () => { it('APIトークンで初期化', () => { const tokenClient = new CloudflareClient({ apiToken: 'test-token' }); expect(tokenClient).toBeDefined(); }); it('レガシーAPIキーで初期化', () => { const keyClient = new CloudflareClient({ email: 'test@example.com', apiKey: 'test-key' }); expect(keyClient).toBeDefined(); }); it('無効な設定でエラー', () => { expect(() => { new CloudflareClient({}); }).toThrow('API token or email/apiKey is required'); }); }); describe('listZones', () => { it('ゾーン一覧を正常に取得', async () => { const mockResponse = { success: true, result: [ { id: 'zone-1', name: 'example.com', status: 'active', account: { id: 'account-1', name: 'Test Account' }, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } ], result_info: { page: 1, per_page: 20, total_pages: 1, count: 1, total_count: 1 } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse) }); const result = await client.listZones(); expect(result.success).toBe(true); expect(result.data).toHaveLength(1); expect(result.data![0].name).toBe('example.com'); expect(result.data![0].id).toBe('zone-1'); }); it('名前フィルターを適用', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: [], result_info: { page: 1, per_page: 20, total_pages: 1, count: 0, total_count: 0 } }) }); await client.listZones({ name: 'test.com' }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('name=test.com'), expect.any(Object) ); }); it('ステータスフィルターを適用', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: [], result_info: { page: 1, per_page: 20, total_pages: 1, count: 0, total_count: 0 } }) }); await client.listZones({ status: 'active' }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('status=active'), expect.any(Object) ); }); it('APIエラーを適切に処理', async () => { const errorResponse = { success: false, errors: [ { code: 10000, message: 'Authentication error' } ] }; mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve(errorResponse) }); const result = await client.listZones(); expect(result.success).toBe(false); expect(result.error).toContain('Authentication error'); }); it('ネットワークエラーを適切に処理', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); const result = await client.listZones(); expect(result.success).toBe(false); expect(result.error).toBe('Network error'); }); }); describe('getZone', () => { it('特定のゾーンを正常に取得', async () => { const mockResponse = { success: true, result: { id: 'zone-1', name: 'example.com', status: 'active', account: { id: 'account-1', name: 'Test Account' }, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse) }); const result = await client.getZone('zone-1'); expect(result.success).toBe(true); expect(result.data!.name).toBe('example.com'); expect(result.data!.id).toBe('zone-1'); }); it('存在しないゾーンでエラー', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({ success: false, errors: [{ code: 1001, message: 'Zone not found' }] }) }); const result = await client.getZone('non-existent'); expect(result.success).toBe(false); expect(result.error).toContain('Zone not found'); }); }); describe('listDNSRecords', () => { it('DNSレコード一覧を正常に取得', async () => { const mockResponse = { success: true, result: [ { id: 'record-1', zone_id: 'zone-1', zone_name: 'example.com', name: 'www.example.com', type: 'A', content: '192.0.2.1', ttl: 300, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } ], result_info: { page: 1, per_page: 20, total_pages: 1, count: 1, total_count: 1 } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse) }); const result = await client.listDNSRecords('zone-1'); expect(result.success).toBe(true); expect(result.data).toHaveLength(1); expect(result.data![0].name).toBe('www.example.com'); expect(result.data![0].type).toBe('A'); expect(result.data![0].content).toBe('192.0.2.1'); }); it('フィルターオプションを適用', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: [], result_info: { page: 1, per_page: 20, total_pages: 1, count: 0, total_count: 0 } }) }); await client.listDNSRecords('zone-1', { type: 'A', name: 'www.example.com', content: '192.0.2.1' }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('type=A'), expect.any(Object) ); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('name=www.example.com'), expect.any(Object) ); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('content=192.0.2.1'), expect.any(Object) ); }); }); describe('createDNSRecord', () => { it('DNSレコードを正常に作成', async () => { const newRecord: Partial<CloudflareDNSRecord> = { name: 'test.example.com', type: 'A', content: '192.0.2.10', ttl: 300 }; const mockResponse = { success: true, result: { id: 'record-new', zone_id: 'zone-1', zone_name: 'example.com', ...newRecord, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse) }); const result = await client.createDNSRecord('zone-1', newRecord); expect(result.success).toBe(true); expect(result.data!.name).toBe('test.example.com'); expect(result.data!.content).toBe('192.0.2.10'); }); it('無効なレコードでエラー', async () => { const invalidRecord: Partial<CloudflareDNSRecord> = { name: '', type: 'A', content: 'invalid-ip' }; mockFetch.mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ success: false, errors: [{ code: 1004, message: 'Invalid record data' }] }) }); const result = await client.createDNSRecord('zone-1', invalidRecord); expect(result.success).toBe(false); expect(result.error).toContain('Invalid record data'); }); }); describe('updateDNSRecord', () => { it('DNSレコードを正常に更新', async () => { const updateData: Partial<CloudflareDNSRecord> = { content: '192.0.2.20', ttl: 600 }; const mockResponse = { success: true, result: { id: 'record-1', zone_id: 'zone-1', zone_name: 'example.com', name: 'www.example.com', type: 'A', content: '192.0.2.20', ttl: 600, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T01:00:00Z' } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse) }); const result = await client.updateDNSRecord('zone-1', 'record-1', updateData); expect(result.success).toBe(true); expect(result.data!.content).toBe('192.0.2.20'); expect(result.data!.ttl).toBe(600); }); }); describe('deleteDNSRecord', () => { it('DNSレコードを正常に削除', async () => { const mockResponse = { success: true, result: { id: 'record-1' } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse) }); const result = await client.deleteDNSRecord('zone-1', 'record-1'); expect(result.success).toBe(true); expect(result.data!.id).toBe('record-1'); }); it('存在しないレコードでエラー', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({ success: false, errors: [{ code: 81044, message: 'Record not found' }] }) }); const result = await client.deleteDNSRecord('zone-1', 'non-existent'); expect(result.success).toBe(false); expect(result.error).toContain('Record not found'); }); }); describe('convertCSVToCloudflareRecords', () => { it('CSVレコードを正しくCloudflareレコードに変換', () => { const csvRecords: ICSVRecord[] = [ { domain: 'example.com', type: 'A', value: '192.0.2.1', ttl: 300 }, { domain: 'mail.example.com', type: 'MX', value: 'mail.example.com', ttl: 300, priority: 10 }, { domain: '_sip._tcp.example.com', type: 'SRV', value: 'sip.example.com', ttl: 300, priority: 10, weight: 20, port: 5060 } ]; const cloudflareRecords = client.convertCSVToCloudflareRecords(csvRecords); expect(cloudflareRecords).toHaveLength(3); // Aレコードの確認 expect(cloudflareRecords[0].name).toBe('example.com'); expect(cloudflareRecords[0].type).toBe('A'); expect(cloudflareRecords[0].content).toBe('192.0.2.1'); // MXレコードの確認(優先度が含まれる) expect(cloudflareRecords[1].name).toBe('mail.example.com'); expect(cloudflareRecords[1].type).toBe('MX'); expect(cloudflareRecords[1].content).toBe('10 mail.example.com'); // SRVレコードの確認(優先度・重み・ポートが含まれる) expect(cloudflareRecords[2].name).toBe('_sip._tcp.example.com'); expect(cloudflareRecords[2].type).toBe('SRV'); expect(cloudflareRecords[2].content).toBe('10 20 5060 sip.example.com'); }); it('空の配列を処理', () => { const result = client.convertCSVToCloudflareRecords([]); expect(result).toEqual([]); }); it('オプション値が未定義の場合も処理', () => { const csvRecords: ICSVRecord[] = [ { domain: 'test.com', type: 'A', value: '192.0.2.1', ttl: 300 // priority, weight, port は undefined } ]; const cloudflareRecords = client.convertCSVToCloudflareRecords(csvRecords); expect(cloudflareRecords).toHaveLength(1); expect(cloudflareRecords[0].content).toBe('192.0.2.1'); }); }); describe('convertCloudflareToCSVRecords', () => { it('CloudflareレコードをCSVレコードに変換', () => { const cloudflareRecords: CloudflareDNSRecord[] = [ { id: 'record-1', zone_id: 'zone-1', zone_name: 'example.com', name: 'example.com', type: 'A', content: '192.0.2.1', ttl: 300, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' }, { id: 'record-2', zone_id: 'zone-1', zone_name: 'example.com', name: 'mail.example.com', type: 'MX', content: '10 mail.example.com', ttl: 300, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } ]; const csvRecords = client.convertCloudflareToCSVRecords(cloudflareRecords); expect(csvRecords).toHaveLength(2); // Aレコードの確認 expect(csvRecords[0].domain).toBe('example.com'); expect(csvRecords[0].type).toBe('A'); expect(csvRecords[0].value).toBe('192.0.2.1'); expect(csvRecords[0].ttl).toBe(300); // MXレコードの確認(優先度が分離される) expect(csvRecords[1].domain).toBe('mail.example.com'); expect(csvRecords[1].type).toBe('MX'); expect(csvRecords[1].value).toBe('mail.example.com'); expect(csvRecords[1].priority).toBe(10); }); it('SRVレコードの変換', () => { const srvRecord: CloudflareDNSRecord = { id: 'record-srv', zone_id: 'zone-1', zone_name: 'example.com', name: '_sip._tcp.example.com', type: 'SRV', content: '10 20 5060 sip.example.com', ttl: 300, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' }; const csvRecords = client.convertCloudflareToCSVRecords([srvRecord]); expect(csvRecords[0].priority).toBe(10); expect(csvRecords[0].weight).toBe(20); expect(csvRecords[0].port).toBe(5060); expect(csvRecords[0].value).toBe('sip.example.com'); }); it('プロキシされたレコードの処理', () => { const proxiedRecord: CloudflareDNSRecord = { id: 'record-proxied', zone_id: 'zone-1', zone_name: 'example.com', name: 'www.example.com', type: 'A', content: '192.0.2.1', ttl: 1, // プロキシ時は自動 proxied: true, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' }; const csvRecords = client.convertCloudflareToCSVRecords([proxiedRecord]); expect(csvRecords[0].ttl).toBe(1); // プロキシ情報は現在のインターフェースでは保持されない }); }); describe('importRecords', () => { it('レコードを正常にインポート', async () => { const csvRecords: ICSVRecord[] = [ { domain: 'import1.example.com', type: 'A', value: '192.0.2.1', ttl: 300 }, { domain: 'import2.example.com', type: 'A', value: '192.0.2.2', ttl: 300 } ]; // 各レコード作成のモック mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, result: { id: 'new-record', zone_id: 'zone-1', zone_name: 'example.com', name: 'test.example.com', type: 'A', content: '192.0.2.1', ttl: 300, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } }) }); const result = await client.importRecords('zone-1', csvRecords); expect(result.success).toBe(true); expect(result.data).toHaveLength(2); expect(mockFetch).toHaveBeenCalledTimes(2); }); it('一部失敗を含むインポート', async () => { const csvRecords: ICSVRecord[] = [ { domain: 'success.example.com', type: 'A', value: '192.0.2.1', ttl: 300 }, { domain: 'fail.example.com', type: 'A', value: 'invalid-ip', ttl: 300 } ]; mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: { id: 'success-record' } }) }) .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ success: false, errors: [{ code: 1004, message: 'Invalid IP' }] }) }); const result = await client.importRecords('zone-1', csvRecords); expect(result.success).toBe(false); expect(result.error).toContain('Invalid IP'); }); it('空の配列のインポート', async () => { const result = await client.importRecords('zone-1', []); expect(result.success).toBe(true); expect(result.data).toEqual([]); expect(mockFetch).not.toHaveBeenCalled(); }); }); describe('exportRecords', () => { it('レコードを正常にエクスポート', async () => { const mockDNSRecords: CloudflareDNSRecord[] = [ { id: 'record-1', zone_id: 'zone-1', zone_name: 'example.com', name: 'www.example.com', type: 'A', content: '192.0.2.1', ttl: 300, proxied: false, created_on: '2023-01-01T00:00:00Z', modified_on: '2023-01-01T00:00:00Z' } ]; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: mockDNSRecords, result_info: { page: 1, per_page: 20, total_pages: 1, count: 1, total_count: 1 } }) }); const result = await client.exportRecords('zone-1'); expect(result.success).toBe(true); expect(result.data).toHaveLength(1); expect(result.data![0].domain).toBe('www.example.com'); expect(result.data![0].type).toBe('A'); expect(result.data![0].value).toBe('192.0.2.1'); }); it('フィルターオプション付きエクスポート', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: [], result_info: { page: 1, per_page: 20, total_pages: 1, count: 0, total_count: 0 } }) }); await client.exportRecords('zone-1', { type: 'A' }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('type=A'), expect.any(Object) ); }); }); describe('認証ヘッダー', () => { it('APIトークン認証ヘッダーを設定', async () => { const tokenClient = new CloudflareClient({ apiToken: 'test-token' }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: [], result_info: { page: 1, per_page: 20, total_pages: 1, count: 0, total_count: 0 } }) }); await tokenClient.listZones(); expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'Authorization': 'Bearer test-token' }) }) ); }); it('レガシーAPIキー認証ヘッダーを設定', async () => { const keyClient = new CloudflareClient({ email: 'test@example.com', apiKey: 'test-key' }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, result: [], result_info: { page: 1, per_page: 20, total_pages: 1, count: 0, total_count: 0 } }) }); await keyClient.listZones(); expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'X-Auth-Email': 'test@example.com', 'X-Auth-Key': 'test-key' }) }) ); }); }); describe('エラーハンドリング', () => { it('JSON解析エラーを処理', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new Error('Invalid JSON')) }); const result = await client.listZones(); expect(result.success).toBe(false); expect(result.error).toBe('Invalid JSON'); }); it('ネットワークタイムアウトを処理', async () => { mockFetch.mockImplementation(() => new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 100) ) ); const result = await client.listZones(); expect(result.success).toBe(false); expect(result.error).toBe('Request timeout'); }); it('レート制限エラーを処理', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 429, json: () => Promise.resolve({ success: false, errors: [{ code: 10013, message: 'Rate limit exceeded' }] }) }); const result = await client.listZones(); expect(result.success).toBe(false); expect(result.error).toContain('Rate limit exceeded'); }); }); describe('パフォーマンス', () => { it('大量レコードのインポートでメモリ効率を保つ', async () => { const largeRecordSet: ICSVRecord[] = Array.from({ length: 1000 }, (_, i) => ({ domain: `test${i}.example.com`, type: 'A' as const, value: '192.0.2.1', ttl: 300 })); mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, result: { id: 'test-record' } }) }); const start = Date.now(); const result = await client.importRecords('zone-1', largeRecordSet); const duration = Date.now() - start; expect(result.success).toBe(true); expect(duration).toBeLessThan(30000); // 30秒以内 }); }); });