- validateRecordWrite enforces closed allowlist (A/AAAA/CNAME/MX/TXT/SRV), rejects NS/SOA case-insensitively with a delegation-specific reason - sanitizeAwsError redacts AWS access key ids, ARNs, and 12-digit account ids, truncates to 500 chars (T-24-03) - no AWS SDK or Postgres dependency; fully unit-tested (23 assertions)
163 lines
6 KiB
TypeScript
163 lines
6 KiB
TypeScript
/**
|
|
* lib/services/route53-record-validation.ts unit tests — D-01 allowlist
|
|
* enforcement and AWS error sanitization (T-24-03). Pure logic, no AWS SDK
|
|
* or Postgres dependency to mock.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
WRITABLE_RECORD_TYPES,
|
|
validateRecordWrite,
|
|
sanitizeAwsError,
|
|
} from './route53-record-validation';
|
|
|
|
describe('WRITABLE_RECORD_TYPES', () => {
|
|
it('is exactly the six D-01 writable types, never NS or SOA', () => {
|
|
expect([...WRITABLE_RECORD_TYPES].sort()).toEqual(
|
|
['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT'].sort()
|
|
);
|
|
expect(WRITABLE_RECORD_TYPES).not.toContain('NS');
|
|
expect(WRITABLE_RECORD_TYPES).not.toContain('SOA');
|
|
});
|
|
});
|
|
|
|
describe('validateRecordWrite', () => {
|
|
const basePayload = {
|
|
name: 'www.example.com',
|
|
resourceRecords: [{ value: '1.2.3.4' }],
|
|
};
|
|
|
|
it('rejects NS with a reason naming it as a zone-delegation record', () => {
|
|
const result = validateRecordWrite({ ...basePayload, type: 'NS' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.status).toBe(400);
|
|
expect(result.reason).toMatch(/NS/);
|
|
expect(result.reason).toMatch(/delegation/i);
|
|
}
|
|
});
|
|
|
|
it('rejects SOA', () => {
|
|
const result = validateRecordWrite({ ...basePayload, type: 'SOA' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects lowercase "ns" case-insensitively', () => {
|
|
const result = validateRecordWrite({ ...basePayload, type: 'ns' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.status).toBe(400);
|
|
expect(result.reason).toMatch(/delegation/i);
|
|
}
|
|
});
|
|
|
|
it.each(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'])(
|
|
'accepts well-formed %s payload',
|
|
(type) => {
|
|
const result = validateRecordWrite({ ...basePayload, type });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.value.type).toBe(type);
|
|
expect(result.value.name).toBe('www.example.com.');
|
|
expect(result.value.ttl).toBe(300);
|
|
}
|
|
}
|
|
);
|
|
|
|
it('rejects an unknown type such as CAA (closed allowlist, not a blocklist)', () => {
|
|
const result = validateRecordWrite({ ...basePayload, type: 'CAA' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects an unknown type such as DS', () => {
|
|
const result = validateRecordWrite({ ...basePayload, type: 'DS' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects a missing name', () => {
|
|
const result = validateRecordWrite({ type: 'A', resourceRecords: [{ value: '1.2.3.4' }] });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects an empty-string name', () => {
|
|
const result = validateRecordWrite({ ...basePayload, name: ' ', type: 'A' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects a non-integer ttl', () => {
|
|
const result = validateRecordWrite({ ...basePayload, type: 'A', ttl: 3.5 });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects a ttl outside 0..2147483647', () => {
|
|
const tooHigh = validateRecordWrite({ ...basePayload, type: 'A', ttl: 2147483648 });
|
|
expect(tooHigh.ok).toBe(false);
|
|
const tooLow = validateRecordWrite({ ...basePayload, type: 'A', ttl: -1 });
|
|
expect(tooLow.ok).toBe(false);
|
|
});
|
|
|
|
it('accepts ttl of exactly 0 and exactly 2147483647', () => {
|
|
const min = validateRecordWrite({ ...basePayload, type: 'A', ttl: 0 });
|
|
expect(min.ok).toBe(true);
|
|
const max = validateRecordWrite({ ...basePayload, type: 'A', ttl: 2147483647 });
|
|
expect(max.ok).toBe(true);
|
|
});
|
|
|
|
it('rejects an empty resourceRecords array (Route 53 rejects an empty value set)', () => {
|
|
const result = validateRecordWrite({ name: 'www.example.com', type: 'A', resourceRecords: [] });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects a resourceRecords entry with an empty-string value', () => {
|
|
const result = validateRecordWrite({
|
|
name: 'www.example.com',
|
|
type: 'A',
|
|
resourceRecords: [{ value: '' }],
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('caps resourceRecords at 100 entries', () => {
|
|
const tooMany = Array.from({ length: 101 }, (_, i) => ({ value: `10.0.0.${i % 256}` }));
|
|
const result = validateRecordWrite({ name: 'www.example.com', type: 'A', resourceRecords: tooMany });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeAwsError', () => {
|
|
it('strips AWS access key ids, ARNs, and account ids, then truncates to 500 chars', () => {
|
|
const err = new Error(
|
|
'AccessDenied for AKIAIOSFODNN7EXAMPLE on arn:aws:route53:::hostedzone/Z123 account 123456789012'
|
|
);
|
|
const sanitized = sanitizeAwsError(err);
|
|
expect(sanitized).not.toContain('AKIAIOSFODNN7EXAMPLE');
|
|
expect(sanitized).not.toContain('arn:aws:route53:::hostedzone/Z123');
|
|
expect(sanitized).not.toContain('123456789012');
|
|
expect(sanitized.length).toBeLessThanOrEqual(504);
|
|
});
|
|
|
|
it('truncates messages longer than 500 characters', () => {
|
|
const longMessage = 'x'.repeat(1000);
|
|
const sanitized = sanitizeAwsError(new Error(longMessage));
|
|
expect(sanitized.length).toBeLessThanOrEqual(504);
|
|
expect(sanitized.endsWith('...')).toBe(true);
|
|
});
|
|
|
|
it('never throws on a non-Error input and returns a string', () => {
|
|
expect(() => sanitizeAwsError('a plain string error')).not.toThrow();
|
|
expect(typeof sanitizeAwsError('a plain string error')).toBe('string');
|
|
expect(() => sanitizeAwsError(undefined)).not.toThrow();
|
|
expect(typeof sanitizeAwsError(undefined)).toBe('string');
|
|
expect(() => sanitizeAwsError({ weird: 'object' })).not.toThrow();
|
|
expect(typeof sanitizeAwsError(null)).toBe('string');
|
|
});
|
|
});
|