a86bee443b
CI / checks (pull_request) Successful in 26m17s
Distributions were linked under localhost.floci.io, another project's domain. Every .localhost name resolves to the machine itself, so the brand-neutral <id>.cloudfront.localhost reaches the runtime with no DNS or hosts entry.
183 lines
11 KiB
TypeScript
183 lines
11 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
CreateDistributionCommand,
|
|
CreateInvalidationCommand,
|
|
DeleteDistributionCommand,
|
|
GetDistributionCommand,
|
|
GetDistributionConfigCommand,
|
|
ListDistributionsCommand,
|
|
ListInvalidationsCommand,
|
|
UpdateDistributionCommand,
|
|
type CloudFrontClient,
|
|
type DistributionConfig,
|
|
} from '@aws-sdk/client-cloudfront'
|
|
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
|
import {AwsCdnAdapter} from './AwsCdnAdapter'
|
|
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
|
|
|
type SendResult = Record<string, unknown>
|
|
|
|
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
const sent: object[] = []
|
|
const client = {
|
|
async send(command: object) {
|
|
sent.push(command)
|
|
return handler(command)
|
|
},
|
|
}
|
|
return {client, sent}
|
|
}
|
|
|
|
function awsError(name: string, status = 400): Error {
|
|
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
|
}
|
|
|
|
const ID = 'E7CPM0P0G0VC8D'
|
|
const origin = {Id: 's3-site', DomainName: 'site.s3.amazonaws.com', S3OriginConfig: {OriginAccessIdentity: ''}}
|
|
const config = {
|
|
CallerReference: 'r', Comment: 'Docs site', Enabled: true, DefaultRootObject: 'index.html',
|
|
Origins: {Quantity: 1, Items: [origin]},
|
|
DefaultCacheBehavior: {TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', AllowedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
|
CacheBehaviors: {Quantity: 1, Items: [{PathPattern: '/api/*', TargetOriginId: 'custom-api', ViewerProtocolPolicy: 'https-only', AllowedMethods: {Quantity: 7, Items: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']}}]},
|
|
} as DistributionConfig
|
|
const distribution = {Id: ID, ARN: `arn:aws:cloudfront::000000000000:distribution/${ID}`, DomainName: 'e7cpm0p0g0vc8d.cloudfront.net', Status: 'Deployed', LastModifiedTime: new Date('2026-10-24T18:00:00Z'), DistributionConfig: config}
|
|
|
|
function adapter(cloudfront: (command: object) => SendResult | Promise<SendResult>, s3: (command: object) => SendResult | Promise<SendResult> = () => ({})) {
|
|
const cf = stub(cloudfront)
|
|
const bucket = stub(s3)
|
|
return {adapter: new AwsCdnAdapter(cf.client as unknown as CloudFrontClient, bucket.client as unknown as S3Client, 'http://floci:4566'), sent: cf.sent, s3Sent: bucket.sent}
|
|
}
|
|
|
|
describe('AwsCdnAdapter distributions', () => {
|
|
test('lists distributions with their local URL, origins and description, and filters them', async () => {
|
|
const {adapter: cdn} = adapter((command) => command instanceof ListDistributionsCommand
|
|
? {DistributionList: {Items: [{...distribution, Enabled: true, Comment: 'Docs site', Origins: config.Origins}], IsTruncated: false}}
|
|
: {})
|
|
|
|
const [listed] = await cdn.list()
|
|
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
|
|
expect(listed.metadata).toMatchObject({viewerUrl: 'http://e7cpm0p0g0vc8d.cloudfront.localhost:4566/', enabled: true, comment: 'Docs site', originDomains: ['site.s3.amazonaws.com']})
|
|
expect(await cdn.list({search: 'site.s3'})).toHaveLength(1)
|
|
expect(await cdn.list({search: 'nothing'})).toHaveLength(0)
|
|
})
|
|
|
|
test('describes behaviours and recent invalidations, and treats a missing distribution as absent', async () => {
|
|
const {adapter: cdn} = adapter((command) => {
|
|
if (command instanceof GetDistributionCommand && command.input.Id === ID) return {Distribution: distribution, ETag: 'e1'}
|
|
if (command instanceof GetDistributionCommand) throw awsError('NoSuchDistribution', 404)
|
|
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: [{Id: 'I1', Status: 'Completed', CreateTime: new Date('2026-10-24T18:05:00Z')}]}}
|
|
return {}
|
|
})
|
|
|
|
const described = await cdn.get(ID)
|
|
expect(described?.metadata).toMatchObject({
|
|
defaultRootObject: 'index.html',
|
|
behaviors: [
|
|
{pathPattern: '/api/*', originId: 'custom-api', viewerProtocol: 'https-only', methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']},
|
|
{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'allow-all', methods: ['GET', 'HEAD']},
|
|
],
|
|
invalidations: [{id: 'I1', status: 'Completed', createdAt: '2026-10-24T18:05:00.000Z', paths: []}],
|
|
})
|
|
expect(await cdn.get('EMISSING')).toBeNull()
|
|
})
|
|
|
|
test('creates a cached website from a bucket that exists', async () => {
|
|
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
|
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
|
: {})
|
|
|
|
await cdn.create({values: {originType: 's3', bucket: 'site', defaultRootObject: '/index.html', comment: 'Docs'}})
|
|
|
|
expect(s3Sent[0]).toBeInstanceOf(HeadBucketCommand)
|
|
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
|
expect(created).toMatchObject({Enabled: true, Comment: 'Docs', DefaultRootObject: 'index.html', Origins: {Quantity: 1, Items: [{Id: 's3-site', DomainName: 'site.s3.amazonaws.com'}]}})
|
|
expect(created.DefaultCacheBehavior).toMatchObject({TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', AllowedMethods: {Quantity: 2}})
|
|
expect(created.DefaultCacheBehavior?.OriginRequestPolicyId).toBeUndefined()
|
|
})
|
|
|
|
test('creates an uncached API in front of a custom origin, forwarding the viewer request', async () => {
|
|
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
|
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
|
: {})
|
|
|
|
await cdn.create({values: {originType: 'custom', originDomain: 'API.example.com', originProtocol: 'http-only', originPort: '8080', allowedMethods: 'all', viewerProtocol: 'redirect-to-https'}})
|
|
|
|
expect(s3Sent).toHaveLength(0)
|
|
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
|
expect(created.Origins?.Items?.[0]).toMatchObject({Id: 'custom-api.example.com', DomainName: 'api.example.com', CustomOriginConfig: {HTTPPort: 8080, HTTPSPort: 8080, OriginProtocolPolicy: 'http-only'}})
|
|
expect(created.DefaultCacheBehavior).toMatchObject({
|
|
ViewerProtocolPolicy: 'redirect-to-https', AllowedMethods: {Quantity: 7},
|
|
CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', OriginRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac',
|
|
})
|
|
})
|
|
|
|
test('refuses a missing bucket and invalid settings before creating anything', async () => {
|
|
const {adapter: cdn, sent} = adapter(() => ({}), () => { throw awsError('NotFound', 404) })
|
|
|
|
await expect(cdn.create({values: {originType: 's3', bucket: 'nope'}})).rejects.toThrow(/no S3 bucket named nope/)
|
|
await expect(cdn.create({values: {originType: 's3', bucket: 'Bad_Name'}})).rejects.toThrow(ValidationError)
|
|
await expect(cdn.create({values: {originType: 'custom', originDomain: 'not a host'}})).rejects.toThrow(ValidationError)
|
|
await expect(cdn.create({values: {originType: 'custom', originDomain: 'api.example.com', originPort: '70000'}})).rejects.toThrow(/originPort/)
|
|
await expect(cdn.create({values: {originType: 'ftp'}})).rejects.toThrow(/originType/)
|
|
expect(sent.some((command) => command instanceof CreateDistributionCommand)).toBe(false)
|
|
})
|
|
|
|
test('disables an enabled distribution, then deletes it with the new ETag', async () => {
|
|
const {adapter: cdn, sent} = adapter((command) => {
|
|
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
|
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
|
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'Deployed'}, ETag: 'e2'}
|
|
return {}
|
|
})
|
|
|
|
await cdn.delete(ID)
|
|
|
|
const update = sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand
|
|
expect(update.input).toMatchObject({Id: ID, IfMatch: 'e1', DistributionConfig: {Enabled: false, Comment: 'Docs site'}})
|
|
expect((sent.find((command) => command instanceof DeleteDistributionCommand) as DeleteDistributionCommand).input).toEqual({Id: ID, IfMatch: 'e2'})
|
|
})
|
|
|
|
test('says to try again while CloudFront is still deploying the disable', async () => {
|
|
const {adapter: cdn, sent} = adapter((command) => {
|
|
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
|
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
|
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'InProgress'}, ETag: 'e2'}
|
|
return {}
|
|
})
|
|
|
|
await expect(cdn.delete(ID)).rejects.toThrow(ConflictError)
|
|
expect(sent.some((command) => command instanceof DeleteDistributionCommand)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('AwsCdnAdapter actions', () => {
|
|
test('enables or disables a distribution, keeping the rest of its configuration', async () => {
|
|
const {adapter: cdn, sent} = adapter((command) => {
|
|
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
|
if (command instanceof GetDistributionCommand) return {Distribution: {...distribution, DistributionConfig: {...config, Enabled: false}}, ETag: 'e2'}
|
|
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: []}}
|
|
return {}
|
|
})
|
|
|
|
const disabled = await cdn.setDistributionEnabled(ID, false)
|
|
expect(disabled.metadata.enabled).toBe(false)
|
|
expect((sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand).input.DistributionConfig).toEqual({...config, Enabled: false})
|
|
|
|
// The stored configuration is already enabled: nothing to change.
|
|
const before = sent.length
|
|
await cdn.setDistributionEnabled(ID, true)
|
|
expect(sent.slice(before).some((command) => command instanceof UpdateDistributionCommand)).toBe(false)
|
|
})
|
|
|
|
test('invalidates paths that start with a slash', async () => {
|
|
const {adapter: cdn, sent} = adapter((command) => command instanceof CreateInvalidationCommand
|
|
? {Invalidation: {Id: 'I2', Status: 'Completed', CreateTime: new Date('2026-10-24T18:10:00Z'), InvalidationBatch: command.input.InvalidationBatch}}
|
|
: {})
|
|
|
|
expect(await cdn.createDistributionInvalidation(ID, [' /* ', '/index.html', '/*'])).toEqual({id: 'I2', status: 'Completed', createdAt: '2026-10-24T18:10:00.000Z', paths: ['/*', '/index.html']})
|
|
await expect(cdn.createDistributionInvalidation(ID, ['index.html'])).rejects.toThrow(/must start with \//)
|
|
await expect(cdn.createDistributionInvalidation(ID, [])).rejects.toThrow(/at least one path/)
|
|
expect(sent.filter((command) => command instanceof CreateInvalidationCommand)).toHaveLength(1)
|
|
})
|
|
})
|