wulf-pulse/lib/services/route53-change-submit.test.ts
lorentz 4da5664184 fix(24-05): quote TXT record values per RFC 1035 character-string format
AWS Route 53 rejects an unquoted TXT Value with:
'InvalidCharacterString (Value should be enclosed in quotation marks)'
— discovered during plan 24-07's live checkpoint (step 2, create) against
a real hosted zone. buildChangeBatch now wraps TXT values in escaped
double quotes, splitting into 255-character segments per RFC 1035's
character-string limit. A/AAAA/CNAME/MX/SRV values pass through
unchanged (only TXT uses the quoted-string wire format).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:08:51 -04:00

248 lines
8.9 KiB
TypeScript

/**
* lib/services/route53-change-submit.ts unit tests — ChangeBatch construction,
* retryable-error classification, and bounded GetChange polling. No AWS SDK
* mocking library, no network — pollChangeStatus is exercised with a
* hand-rolled fake client exposing `send()`.
*/
import { describe, it, expect, vi } from 'vitest';
import {
buildChangeBatch,
isRetryableAwsError,
submitRecordChange,
pollChangeStatus,
} from './route53-change-submit';
describe('buildChangeBatch', () => {
const baseRecordSet = {
name: 'www.example.com.',
type: 'A',
ttl: 300,
resourceRecords: [{ value: '1.2.3.4' }],
};
it('produces the expected ChangeBatch shape for UPSERT', () => {
const batch = buildChangeBatch('UPSERT', baseRecordSet);
expect(batch).toEqual({
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet: {
Name: 'www.example.com.',
Type: 'A',
TTL: 300,
ResourceRecords: [{ Value: '1.2.3.4' }],
},
},
],
});
});
it('sets Action to CREATE for a CREATE action', () => {
const batch = buildChangeBatch('CREATE', baseRecordSet);
expect(batch.Changes?.[0].Action).toBe('CREATE');
});
it('sets Action to DELETE for a DELETE action', () => {
const batch = buildChangeBatch('DELETE', baseRecordSet);
expect(batch.Changes?.[0].Action).toBe('DELETE');
});
it('emits SetIdentifier when present on the input', () => {
const batch = buildChangeBatch('UPSERT', { ...baseRecordSet, setIdentifier: 'primary' });
expect(batch.Changes?.[0].ResourceRecordSet?.SetIdentifier).toBe('primary');
});
it('omits SetIdentifier entirely when null/undefined rather than emitting undefined', () => {
const batchNull = buildChangeBatch('UPSERT', { ...baseRecordSet, setIdentifier: null });
expect('SetIdentifier' in (batchNull.Changes?.[0].ResourceRecordSet ?? {})).toBe(false);
const batchUndefined = buildChangeBatch('UPSERT', baseRecordSet);
expect('SetIdentifier' in (batchUndefined.Changes?.[0].ResourceRecordSet ?? {})).toBe(false);
});
it('emits the full TTL and complete ResourceRecords array for a DELETE from supplied current state', () => {
const batch = buildChangeBatch('DELETE', {
...baseRecordSet,
ttl: 600,
resourceRecords: [{ value: '1.2.3.4' }, { value: '5.6.7.8' }],
});
const rrs = batch.Changes?.[0].ResourceRecordSet;
expect(rrs?.TTL).toBe(600);
expect(rrs?.ResourceRecords).toEqual([{ Value: '1.2.3.4' }, { Value: '5.6.7.8' }]);
});
it('throws when given record type NS', () => {
expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'NS' })).toThrow(/NS/);
});
it('throws when given record type SOA (case-insensitive)', () => {
expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'soa' })).toThrow(/SOA/i);
});
describe('TXT record quoting (RFC 1035 character-string)', () => {
it('wraps a plain TXT value in double quotes — AWS rejects an unquoted Value with InvalidCharacterString', () => {
const batch = buildChangeBatch('UPSERT', {
...baseRecordSet,
type: 'TXT',
resourceRecords: [{ value: 'phase24-verification' }],
});
expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords).toEqual([
{ Value: '"phase24-verification"' },
]);
});
it('escapes embedded double quotes and backslashes before quoting', () => {
const batch = buildChangeBatch('UPSERT', {
...baseRecordSet,
type: 'TXT',
resourceRecords: [{ value: 'v=spf1 include:"weird\\path" ~all' }],
});
expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords?.[0].Value).toBe(
'"v=spf1 include:\\"weird\\\\path\\" ~all"'
);
});
it('splits a value over 255 characters into multiple quoted segments', () => {
const long = 'a'.repeat(300);
const batch = buildChangeBatch('UPSERT', {
...baseRecordSet,
type: 'TXT',
resourceRecords: [{ value: long }],
});
const value = batch.Changes?.[0].ResourceRecordSet?.ResourceRecords?.[0].Value ?? '';
expect(value).toBe(`"${'a'.repeat(255)}" "${'a'.repeat(45)}"`);
});
it('does not quote non-TXT record values', () => {
const batch = buildChangeBatch('UPSERT', baseRecordSet);
expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords).toEqual([{ Value: '1.2.3.4' }]);
});
});
});
describe('isRetryableAwsError', () => {
it.each(['ThrottlingException', 'PriorRequestNotComplete', 'Throttling', 'ServiceUnavailable'])(
'returns true for %s',
(name) => {
const err = new Error('boom');
err.name = name;
expect(isRetryableAwsError(err)).toBe(true);
}
);
it('returns false for InvalidChangeBatch', () => {
const err = new Error('boom');
err.name = 'InvalidChangeBatch';
expect(isRetryableAwsError(err)).toBe(false);
});
it('returns false for a non-Error input', () => {
expect(isRetryableAwsError('not an error')).toBe(false);
expect(isRetryableAwsError(undefined)).toBe(false);
});
});
describe('submitRecordChange', () => {
const recordSet = {
name: 'www.example.com.',
type: 'A',
ttl: 300,
resourceRecords: [{ value: '1.2.3.4' }],
};
it('sends a ChangeResourceRecordSetsCommand and returns the change id', async () => {
const send = vi.fn().mockResolvedValue({
ChangeInfo: { Id: '/change/C123', Status: 'PENDING' },
});
const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];
const result = await submitRecordChange({
zoneId: 'Z123',
action: 'UPSERT',
recordSet,
client: fakeClient,
});
expect(result.changeId).toBe('/change/C123');
expect(send).toHaveBeenCalledTimes(1);
});
it('retries up to 2 additional times on a retryable error, then succeeds', async () => {
const throttling = new Error('throttled');
throttling.name = 'ThrottlingException';
const send = vi
.fn()
.mockRejectedValueOnce(throttling)
.mockRejectedValueOnce(throttling)
.mockResolvedValue({ ChangeInfo: { Id: '/change/C456', Status: 'PENDING' } });
const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];
const result = await submitRecordChange({
zoneId: 'Z123',
action: 'UPSERT',
recordSet,
client: fakeClient,
});
expect(result.changeId).toBe('/change/C456');
expect(send).toHaveBeenCalledTimes(3);
}, 10000);
it('rethrows immediately on a non-retryable error without retrying', async () => {
const invalid = new Error('invalid batch');
invalid.name = 'InvalidChangeBatch';
const send = vi.fn().mockRejectedValue(invalid);
const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];
await expect(
submitRecordChange({ zoneId: 'Z123', action: 'UPSERT', recordSet, client: fakeClient })
).rejects.toThrow('invalid batch');
expect(send).toHaveBeenCalledTimes(1);
});
});
describe('pollChangeStatus', () => {
it('returns INSYNC as soon as the client reports ChangeInfo.Status === INSYNC', async () => {
const send = vi.fn().mockResolvedValue({ ChangeInfo: { Status: 'INSYNC' } });
const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
| { client?: infer C }
| undefined
? C
: never;
const status = await pollChangeStatus('/change/C1', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
expect(status).toBe('INSYNC');
expect(send).toHaveBeenCalledTimes(1);
});
it('returns PENDING once the timeout budget elapses without an INSYNC answer, and stops calling send() after returning', async () => {
const send = vi.fn().mockResolvedValue({ ChangeInfo: { Status: 'PENDING' } });
const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
| { client?: infer C }
| undefined
? C
: never;
const status = await pollChangeStatus('/change/C2', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
expect(status).toBe('PENDING');
const countAfterReturn = send.mock.calls.length;
// Wait longer than the timeout budget to confirm no further calls happen.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(send.mock.calls.length).toBe(countAfterReturn);
});
it('swallows a per-attempt GetChange error and keeps polling until the budget elapses', async () => {
const send = vi.fn().mockRejectedValue(new Error('transient network blip'));
const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
| { client?: infer C }
| undefined
? C
: never;
const status = await pollChangeStatus('/change/C3', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
expect(status).toBe('PENDING');
expect(send.mock.calls.length).toBeGreaterThan(1);
});
});