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 function stub(handler: (command: object) => SendResult | Promise) { 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, s3: (command: object) => SendResult | Promise = () => ({})) { 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) }) })