test(24-02): add Route 53 record-key, normalization, and drift-classification helpers
- buildRecordKey, normalizeRecordSet, recordSetsEqual, classifyDrift, toHistoryPayload - Pure, dependency-free module (no pg, no AWS client construction) - 16 unit tests covering every behavior bullet from the plan
This commit is contained in:
parent
ecd4dabd67
commit
c18271dda9
2 changed files with 291 additions and 0 deletions
174
lib/services/route53-record-key.test.ts
Normal file
174
lib/services/route53-record-key.test.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
117
lib/services/route53-record-key.ts
Normal file
117
lib/services/route53-record-key.ts
Normal file
|
|
@ -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<string, unknown> | 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<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue