import {describe, expect, test} from 'bun:test' import { ChangeResourceRecordSetsCommand, CreateHostedZoneCommand, DeleteHostedZoneCommand, GetHostedZoneCommand, ListHostedZonesCommand, ListResourceRecordSetsCommand, type ResourceRecordSet, type Route53Client, } from '@aws-sdk/client-route-53' import {AwsDnsAdapter} from './AwsDnsAdapter' import {ConflictError, ValidationError} from '../cloud-spi/errors' type SendResult = Record function stubRoute53(handler: (command: object) => SendResult | Promise) { const sent: object[] = [] const client = { async send(command: object) { sent.push(command) return handler(command) }, } as unknown as Route53Client return {client, sent} } function awsError(name: string, status = 400): Error { return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}}) } const ZONE_ID = 'ZG9PAE0M9KRWJ4F' const zone = {Id: `/hostedzone/${ZONE_ID}`, Name: 'example.test.', CallerReference: 'r', ResourceRecordSetCount: 3, Config: {Comment: 'Local zone', PrivateZone: false}} const apex = [ {Name: 'example.test.', Type: 'NS', TTL: 172800, ResourceRecords: [{Value: 'ns-1.awsdns-01.org.'}]}, {Name: 'example.test.', Type: 'SOA', TTL: 900, ResourceRecords: [{Value: 'ns-1.awsdns-01.org awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400'}]}, ] function zoneStub(records: object[] = apex, more: (command: object) => SendResult | undefined = () => undefined) { return stubRoute53((command) => { const extra = more(command) if (extra) return extra if (command instanceof GetHostedZoneCommand) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}} if (command instanceof ListResourceRecordSetsCommand) return {ResourceRecordSets: records, IsTruncated: false} return {} }) } describe('AwsDnsAdapter hosted zones', () => { test('lists zones by bare ID and filters by name', async () => { const {client} = stubRoute53((command) => { if (command instanceof ListHostedZonesCommand) { return {HostedZones: [zone, {...zone, Id: '/hostedzone/ZPRIVATE', Name: 'internal.test.', Config: {PrivateZone: true}}], IsTruncated: false} } return {} }) const adapter = new AwsDnsAdapter(client) const zones = await adapter.list() expect(zones.map((resource) => [resource.id, resource.name, resource.metadata.visibility])).toEqual([[ZONE_ID, 'example.test', 'public'], ['ZPRIVATE', 'internal.test', 'private']]) expect((await adapter.list({search: 'INTERNAL'})).map((resource) => resource.id)).toEqual(['ZPRIVATE']) }) test('describes a zone with its name servers, and treats a missing zone as absent', async () => { const {client} = stubRoute53((command) => { if (command instanceof GetHostedZoneCommand && command.input.Id === ZONE_ID) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}} throw awsError('NoSuchHostedZone', 404) }) const adapter = new AwsDnsAdapter(client) expect((await adapter.get(ZONE_ID))?.metadata).toEqual({visibility: 'public', recordCount: 3, comment: 'Local zone', nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}) expect(await adapter.get('ZMISSING')).toBeNull() }) test('creates a public zone, or a private one in a VPC', async () => { const {client, sent} = stubRoute53((command) => command instanceof CreateHostedZoneCommand ? {HostedZone: {...zone, Name: `${command.input.Name}.`, Config: command.input.HostedZoneConfig}, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}} : {}) const adapter = new AwsDnsAdapter(client) expect((await adapter.create({values: {name: ' Example.TEST ', comment: 'Local zone'}})).name).toBe('example.test') await adapter.create({values: {name: 'internal.test', vpcId: 'vpc-0abc1234'}}) const [publicZone, privateZone] = sent.filter((command) => command instanceof CreateHostedZoneCommand) as CreateHostedZoneCommand[] expect(publicZone.input).toMatchObject({Name: 'example.test', HostedZoneConfig: {Comment: 'Local zone', PrivateZone: false}}) expect(publicZone.input.VPC).toBeUndefined() expect(privateZone.input).toMatchObject({HostedZoneConfig: {PrivateZone: true}, VPC: {VPCId: 'vpc-0abc1234', VPCRegion: 'us-east-1'}}) await expect(adapter.create({values: {name: 'not a domain'}})).rejects.toThrow(ValidationError) await expect(adapter.create({values: {name: 'example.test', vpcId: 'subnet-1'}})).rejects.toThrow(ValidationError) }) test('explains that a zone with records cannot be deleted', async () => { const {client} = stubRoute53((command) => { if (command instanceof DeleteHostedZoneCommand) throw awsError('HostedZoneNotEmpty') return {} }) await expect(new AwsDnsAdapter(client).delete(ZONE_ID)).rejects.toThrow(ConflictError) }) }) describe('AwsDnsAdapter records', () => { test('lists records keyed by name and type, marking the zone\'s own NS and SOA', async () => { const {client} = zoneStub([ ...apex, {Name: '\\052.example.test.', Type: 'A', TTL: 60, ResourceRecords: [{Value: '10.0.0.9'}]}, {Name: 'cdn.example.test.', Type: 'A', AliasTarget: {DNSName: 'd111.cloudfront.net.', HostedZoneId: 'Z2FDTNDATAQYW2', EvaluateTargetHealth: false}}, {Name: 'api.example.test.', Type: 'A', SetIdentifier: 'blue', Weight: 10, TTL: 60, ResourceRecords: [{Value: '10.0.0.2'}]}, ]) const {items} = await new AwsDnsAdapter(client).items.listItems(ZONE_ID) expect(items.map((item) => item.id)).toEqual(['example.test|NS', 'example.test|SOA', '*.example.test|A', 'cdn.example.test|A', 'api.example.test|A|blue']) expect(items[0].body).toMatchObject({managed: true, editable: true}) expect(items[2].body).toMatchObject({name: '*.example.test', ttl: 60, values: ['10.0.0.9'], managed: false, editable: true}) expect(items[3].body).toMatchObject({alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2FDTNDATAQYW2', evaluateTargetHealth: false}, editable: false}) expect(items[4].body).toMatchObject({setIdentifier: 'blue', editable: false}) }) test('pages with an opaque cursor built from Route 53\'s next record', async () => { const {client, sent} = zoneStub(apex, (command) => command instanceof ListResourceRecordSetsCommand && !command.input.StartRecordName ? {ResourceRecordSets: apex, IsTruncated: true, NextRecordName: 'www.example.test.', NextRecordType: 'A'} : undefined) const store = new AwsDnsAdapter(client).items const first = await store.listItems(ZONE_ID, {limit: 2}) expect(first.nextCursor).toBeTruthy() await store.listItems(ZONE_ID, {cursor: first.nextCursor!}) const second = sent.filter((command) => command instanceof ListResourceRecordSetsCommand).at(-1) as ListResourceRecordSetsCommand expect(second.input).toMatchObject({StartRecordName: 'www.example.test.', StartRecordType: 'A'}) await expect(store.listItems(ZONE_ID, {cursor: 'not-a-cursor'})).rejects.toThrow(ValidationError) }) test('upserts a record, naming it inside the zone and quoting TXT text', async () => { const {client, sent} = zoneStub() const store = new AwsDnsAdapter(client).items expect((await store.putItem!(ZONE_ID, {name: 'www', type: 'a', ttl: '120', values: ['10.0.0.1', '10.0.0.2']})).id).toBe('www.example.test|A') await store.putItem!(ZONE_ID, {name: '@', type: 'TXT', values: 'v=spf1 -all\n"already quoted"'}) await store.putItem!(ZONE_ID, {name: 'mail.example.test.', type: 'MX', values: ['10 mx.example.test']}) const changes = sent.filter((command) => command instanceof ChangeResourceRecordSetsCommand) .map((command) => (command as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0]) expect(changes[0]).toEqual({Action: 'UPSERT', ResourceRecordSet: {Name: 'www.example.test', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}, {Value: '10.0.0.2'}]}}) expect(changes[1].ResourceRecordSet).toMatchObject({Name: 'example.test', Type: 'TXT', TTL: 300, ResourceRecords: [{Value: '"v=spf1 -all"'}, {Value: '"already quoted"'}]}) expect(changes[2].ResourceRecordSet).toMatchObject({Name: 'mail.example.test', Type: 'MX'}) }) test('applies Route 53\'s value rules before calling it', async () => { const {client, sent} = zoneStub() const store = new AwsDnsAdapter(client).items await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: ['not-an-ip']})).rejects.toThrow(/IPv4/) await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'AAAA', values: ['10.0.0.1']})).rejects.toThrow(/IPv6/) await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'CNAME', values: ['a.test', 'b.test']})).rejects.toThrow(/exactly one/) await expect(store.putItem!(ZONE_ID, {name: '@', type: 'CNAME', values: ['a.test']})).rejects.toThrow(/apex/) await expect(store.putItem!(ZONE_ID, {name: 'mail', type: 'MX', values: ['mx.example.test']})).rejects.toThrow(/priority/) await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'BOGUS', values: ['x']})).rejects.toThrow(/type/) await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: []})).rejects.toThrow(/value/) await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', ttl: -1, values: ['10.0.0.1']})).rejects.toThrow(/ttl/) expect(sent.some((command) => command instanceof ChangeResourceRecordSetsCommand)).toBe(false) }) test('deletes exactly the stored record set, and never the zone\'s own NS or SOA', async () => { const stored: ResourceRecordSet = {Name: 'www.example.test.', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}]} const {client, sent} = zoneStub([stored]) const store = new AwsDnsAdapter(client).items await store.deleteItem!(ZONE_ID, 'www.example.test|A') const change = (sent.find((command) => command instanceof ChangeResourceRecordSetsCommand) as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0] expect(change).toEqual({Action: 'DELETE', ResourceRecordSet: stored}) await expect(store.deleteItem!(ZONE_ID, 'example.test|NS')).rejects.toThrow(ConflictError) await expect(store.deleteItem!(ZONE_ID, 'gone.example.test|A')).rejects.toThrow(/not found/) }) })