diff --git a/lib/services/route53-record-key.test.ts b/lib/services/route53-record-key.test.ts new file mode 100644 index 0000000..fc36c19 --- /dev/null +++ b/lib/services/route53-record-key.test.ts @@ -0,0 +1,174 @@ +/** + * lib/services/route53-record-key.ts unit tests. + * + * Pure functions, no mocking required — no `pg`, no AWS client construction. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildRecordKey, + normalizeRecordSet, + recordSetsEqual, + classifyDrift, + toHistoryPayload, + type NormalizedRecordSet, +} from './route53-record-key'; +import type { ResourceRecordSet } from '@aws-sdk/client-route-53'; + +describe('buildRecordKey', () => { + it('builds a key with an empty setIdentifier segment when none is given', () => { + expect( + buildRecordKey({ zoneId: 'Z123', name: 'www.example.com.', type: 'A', setIdentifier: null }) + ).toBe('Z123:www.example.com.:A:'); + }); + + it('appends a non-null setIdentifier after the final colon', () => { + expect( + buildRecordKey({ zoneId: 'Z123', name: 'www.example.com.', type: 'A', setIdentifier: 'primary' }) + ).toBe('Z123:www.example.com.:A:primary'); + }); +}); + +describe('normalizeRecordSet', () => { + it('lowercases name, preserves trailing dot, uppercases type, coerces missing TTL to null, sorts resourceRecords', () => { + const rs: ResourceRecordSet = { + Name: 'WWW.Example.COM.', + Type: 'a' as ResourceRecordSet['Type'], + ResourceRecords: [{ Value: '10.0.0.2' }, { Value: '10.0.0.1' }], + }; + const normalized = normalizeRecordSet(rs, 'Z123'); + + expect(normalized.name).toBe('www.example.com.'); + expect(normalized.type).toBe('A'); + expect(normalized.ttl).toBeNull(); + expect(normalized.resourceRecords).toEqual([{ value: '10.0.0.1' }, { value: '10.0.0.2' }]); + }); + + it('normalizes an alias record with no TTL/ResourceRecords but a populated AliasTarget', () => { + const rs: ResourceRecordSet = { + Name: 'alias.example.com.', + Type: 'A' as ResourceRecordSet['Type'], + AliasTarget: { + HostedZoneId: 'Z2FDTNDATAQYW2', + DNSName: 'd123.cloudfront.net.', + EvaluateTargetHealth: false, + }, + }; + const normalized = normalizeRecordSet(rs, 'Z123'); + + expect(normalized.ttl).toBeNull(); + expect(normalized.resourceRecords).toEqual([]); + expect(normalized.aliasTarget).toEqual({ + HostedZoneId: 'Z2FDTNDATAQYW2', + DNSName: 'd123.cloudfront.net.', + EvaluateTargetHealth: false, + }); + }); +}); + +describe('recordSetsEqual', () => { + const base: NormalizedRecordSet = { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }, { value: '10.0.0.2' }], + aliasTarget: null, + }; + + it('is true when compared with itself', () => { + expect(recordSetsEqual(base, base)).toBe(true); + }); + + it('is false when a single resourceRecords value changes', () => { + const changed: NormalizedRecordSet = { + ...base, + resourceRecords: [{ value: '10.0.0.1' }, { value: '10.0.0.3' }], + }; + expect(recordSetsEqual(base, changed)).toBe(false); + }); + + it('is true when only the order of resourceRecords changes', () => { + const reordered: NormalizedRecordSet = { + ...base, + resourceRecords: [{ value: '10.0.0.2' }, { value: '10.0.0.1' }], + }; + // Both are pre-sorted by normalizeRecordSet in real use; simulate that + // order doesn't matter by sorting here too, matching normalization. + const sorted = [...reordered.resourceRecords].sort((a, b) => a.value.localeCompare(b.value)); + expect(recordSetsEqual(base, { ...reordered, resourceRecords: sorted })).toBe(true); + }); + + it('is false when TTL differs', () => { + const changed: NormalizedRecordSet = { ...base, ttl: 600 }; + expect(recordSetsEqual(base, changed)).toBe(false); + }); + + it('is true when both are null', () => { + expect(recordSetsEqual(null, null)).toBe(true); + }); + + it('is false when only one side is null', () => { + expect(recordSetsEqual(base, null)).toBe(false); + expect(recordSetsEqual(null, base)).toBe(false); + }); +}); + +describe('classifyDrift', () => { + const prev: NormalizedRecordSet = { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }; + + it('returns create when prev is null', () => { + expect(classifyDrift(null, prev)).toBe('create'); + }); + + it('returns delete when next is null', () => { + expect(classifyDrift(prev, null)).toBe('delete'); + }); + + it('returns update when normalized sets differ', () => { + const next: NormalizedRecordSet = { ...prev, ttl: 600 }; + expect(classifyDrift(prev, next)).toBe('update'); + }); + + it('returns null when normalized sets are equal (no history row)', () => { + expect(classifyDrift(prev, { ...prev })).toBeNull(); + }); +}); + +describe('toHistoryPayload', () => { + it('returns null for a null input', () => { + expect(toHistoryPayload(null)).toBeNull(); + }); + + it('returns the whole-recordset snapshot shape for a non-null input', () => { + const ns: NormalizedRecordSet = { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }; + expect(toHistoryPayload(ns)).toEqual({ + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }); + }); +}); diff --git a/lib/services/route53-record-key.ts b/lib/services/route53-record-key.ts new file mode 100644 index 0000000..60c7dcb --- /dev/null +++ b/lib/services/route53-record-key.ts @@ -0,0 +1,117 @@ +/** + * Pure, dependency-free helpers for Route 53 record-key derivation, + * recordset normalization, and drift classification. + * + * No `pg` import, no AWS SDK client construction — only types are imported + * from `@aws-sdk/client-route-53`. This keeps the module unit-testable + * without mocking anything (see 24-RESEARCH.md Anti-Patterns: no per-value + * diffing; Route 53 models an update as a whole-recordset replace). + */ + +import type { ResourceRecordSet } from '@aws-sdk/client-route-53'; + +export interface NormalizedRecordSet { + recordKey: string; + zoneId: string; + name: string; + type: string; + setIdentifier: string | null; + ttl: number | null; + resourceRecords: Array<{ value: string }>; + aliasTarget: Record | null; +} + +/** + * Build the `route53_records.record_key` primary key / `recordId` URL + * segment: `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`. + */ +export function buildRecordKey(input: { + zoneId: string; + name: string; + type: string; + setIdentifier?: string | null; +}): string { + return `${input.zoneId}:${input.name}:${input.type}:${input.setIdentifier ?? ''}`; +} + +/** + * Normalize a raw AWS `ResourceRecordSet` into a comparable, stable shape. + * + * - `name` is lowercased with its trailing dot preserved + * - `type` is uppercased + * - `ttl` is `rs.TTL ?? null` (alias records have no TTL) + * - `resourceRecords` is sorted by value so AWS's unordered list doesn't + * register as drift on every sync + * - `aliasTarget` is `rs.AliasTarget ?? null` + */ +export function normalizeRecordSet(rs: ResourceRecordSet, zoneId: string): NormalizedRecordSet { + const name = (rs.Name ?? '').toLowerCase(); + const type = (rs.Type ?? '').toUpperCase(); + const setIdentifier = rs.SetIdentifier ?? null; + const ttl = rs.TTL ?? null; + const resourceRecords = (rs.ResourceRecords ?? []) + .map((r) => ({ value: r.Value ?? '' })) + .sort((a, b) => a.value.localeCompare(b.value)); + const aliasTarget = (rs.AliasTarget as unknown as Record | undefined) ?? null; + + return { + recordKey: buildRecordKey({ zoneId, name, type, setIdentifier }), + zoneId, + name, + type, + setIdentifier, + ttl, + resourceRecords, + aliasTarget, + }; +} + +/** + * Compare two normalized recordsets for material equality: TTL, the + * serialized (already-sorted) resourceRecords array, and the serialized + * aliasTarget. Both null returns true; exactly one null returns false. + */ +export function recordSetsEqual(a: NormalizedRecordSet | null, b: NormalizedRecordSet | null): boolean { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + + if (a.ttl !== b.ttl) return false; + if (JSON.stringify(a.resourceRecords) !== JSON.stringify(b.resourceRecords)) return false; + if (JSON.stringify(a.aliasTarget) !== JSON.stringify(b.aliasTarget)) return false; + + return true; +} + +/** + * Classify the drift between a previous (mirror) and next (AWS-live) + * normalized recordset, mapping to `route53_record_history.change_action`. + * Returns `null` when there is no material difference, so the sync does not + * write a no-op history row. + */ +export function classifyDrift( + prev: NormalizedRecordSet | null, + next: NormalizedRecordSet | null +): 'create' | 'update' | 'delete' | null { + if (prev === null && next === null) return null; + if (prev === null) return 'create'; + if (next === null) return 'delete'; + return recordSetsEqual(prev, next) ? null : 'update'; +} + +/** + * Build the JSONB payload stored in `before_value`/`after_value`: the whole + * recordset snapshot. Route 53 has no partial-value primitive, so the unit + * of change recorded in history is always the whole recordset, never a + * per-field delta. + */ +export function toHistoryPayload(ns: NormalizedRecordSet | null): unknown { + if (ns === null) return null; + return { + name: ns.name, + type: ns.type, + setIdentifier: ns.setIdentifier, + ttl: ns.ttl, + resourceRecords: ns.resourceRecords, + aliasTarget: ns.aliasTarget, + }; +}