From 4dd9d5dab85fb05c935907f584cb85f84e62354d Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 19:24:28 -0400 Subject: [PATCH] test(24-01): add failing test for Route 53 credential factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/types/route53.ts: camelCase interfaces for zones/records/history/audit-log/sync-result - lib/services/route53-factory.test.ts: isRoute53Configured() + getRoute53Client() behavior cases — fails RED, factory module does not exist yet --- lib/services/route53-factory.test.ts | 63 +++++++++++++++++ lib/types/route53.ts | 100 +++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 lib/services/route53-factory.test.ts create mode 100644 lib/types/route53.ts diff --git a/lib/services/route53-factory.test.ts b/lib/services/route53-factory.test.ts new file mode 100644 index 0000000..74a7d5f --- /dev/null +++ b/lib/services/route53-factory.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { isRoute53Configured, getRoute53Client, resetRoute53Client } from './route53-factory'; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_REGION; + resetRoute53Client(); +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + resetRoute53Client(); +}); + +describe('isRoute53Configured', () => { + it('returns false when AWS_ACCESS_KEY_ID is unset', () => { + process.env.AWS_SECRET_ACCESS_KEY = 'secret1'; + expect(isRoute53Configured()).toBe(false); + }); + + it('returns false when AWS_SECRET_ACCESS_KEY is unset', () => { + process.env.AWS_ACCESS_KEY_ID = 'id1'; + expect(isRoute53Configured()).toBe(false); + }); + + it('returns false when both are set to empty strings', () => { + process.env.AWS_ACCESS_KEY_ID = ''; + process.env.AWS_SECRET_ACCESS_KEY = ''; + expect(isRoute53Configured()).toBe(false); + }); + + it('returns true when both are set to non-empty values', () => { + process.env.AWS_ACCESS_KEY_ID = 'id1'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret1'; + expect(isRoute53Configured()).toBe(true); + }); +}); + +describe('getRoute53Client', () => { + it('throws an Error mentioning AWS_ACCESS_KEY_ID when credentials are absent', () => { + expect(() => getRoute53Client()).toThrow(/AWS_ACCESS_KEY_ID/); + }); + + it('returns the same cached instance on a second call', () => { + process.env.AWS_ACCESS_KEY_ID = 'id1'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret1'; + const first = getRoute53Client(); + const second = getRoute53Client(); + expect(second).toBe(first); + }); + + it('returns a different instance after resetRoute53Client()', () => { + process.env.AWS_ACCESS_KEY_ID = 'id1'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret1'; + const first = getRoute53Client(); + resetRoute53Client(); + const second = getRoute53Client(); + expect(second).not.toBe(first); + }); +}); diff --git a/lib/types/route53.ts b/lib/types/route53.ts new file mode 100644 index 0000000..54782fd --- /dev/null +++ b/lib/types/route53.ts @@ -0,0 +1,100 @@ +/** + * AWS Route 53 DNS sync — shared type definitions. + * API-response shapes (camelCase) — route handlers transform snake_case + * Postgres rows into these manually (no ORM, per CLAUDE.md). + */ + +// ============================================================================ +// Zone / Record mirror types +// ============================================================================ + +export interface Route53Zone { + id: string; + name: string; + comment: string | null; + privateZone: boolean; + recordCount: number; + authoritativeNameServers: string[] | null; + syncedAt: string; + isDeleted: boolean; +} + +export interface Route53RecordValue { + value: string; +} + +export interface Route53Record { + recordKey: string; + zoneId: string; + name: string; + type: string; + setIdentifier: string | null; + ttl: number | null; + resourceRecords: Route53RecordValue[] | null; + aliasTarget: Record | null; + syncedAt: string; + isDeleted: boolean; +} + +// ============================================================================ +// Change ledger / audit log types +// ============================================================================ + +export type Route53HistorySource = 'pulse_crud' | 'sync_detected_drift'; + +export interface Route53RecordHistory { + id: string; + zoneId: string; + recordKey: string; + recordName: string; + recordType: string; + changeAction: 'create' | 'update' | 'delete'; + beforeValue: Record | null; + afterValue: Record | null; + source: Route53HistorySource; + changedByUserId: string | null; + changedByEmail: string | null; + changedAt: string; +} + +export type Route53AuditStatus = 'pending' | 'committed' | 'failed'; + +export interface Route53AuditLog { + id: string; + operation: 'create' | 'update' | 'delete' | 'sync'; + zoneId: string | null; + recordKey: string | null; + recordName: string | null; + recordType: string | null; + beforeValue: Record | null; + afterValue: Record | null; + performedByUserId: string | null; + performedByEmail: string | null; + performedAt: string; + completedAt: string | null; + status: Route53AuditStatus; + awsChangeId: string | null; + awsChangeStatus: string | null; + errorMessage: string | null; +} + +// ============================================================================ +// Writable record types (D-01) +// ============================================================================ + +export type Route53WritableType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV'; + +// ============================================================================ +// Sync result +// ============================================================================ + +export interface Route53SyncResult { + syncId: string; + syncType: string; + status: string; + startedAt: string; + completedAt: string | null; + duration: number | null; + entities: Record; + errors: string[]; +}