175 lines
11 KiB
TypeScript
175 lines
11 KiB
TypeScript
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<string, unknown>
|
|
|
|
function stubRoute53(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
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/)
|
|
})
|
|
})
|