import {describe, expect, test} from 'bun:test' import { type ACMClient, DeleteCertificateCommand, DescribeCertificateCommand, ExportCertificateCommand, GetCertificateCommand, ImportCertificateCommand, ListCertificatesCommand, RequestCertificateCommand, RevokeCertificateCommand, } from '@aws-sdk/client-acm' import {AwsCertificatesAdapter} from './AwsCertificatesAdapter' import {ValidationError} from '../cloud-spi/errors' type SendResult = Record function stubAcm(handler: (command: object) => SendResult | Promise) { const sent: object[] = [] const client = { async send(command: object) { sent.push(command) return handler(command) }, } as unknown as ACMClient return {client, sent} } function awsError(name: string, status = 400): Error { return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}}) } const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba' const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}` const detail = { CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'], DomainValidationOptions: [{ DomainName: 'notes.thecloud.localhost', ValidationStatus: 'SUCCESS', ValidationMethod: 'DNS', ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'}, }], Status: 'ISSUED', Type: 'AMAZON_ISSUED', KeyAlgorithm: 'RSA-2048', Issuer: 'CN=Floci Local CA', CreatedAt: new Date('2026-10-25T16:28:12Z'), NotAfter: new Date('2027-10-25T16:28:12Z'), InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'], Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'}, } function adapterWith(handler: (command: object) => SendResult | Promise) { const stub = stubAcm(handler) return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')} } describe('AwsCertificatesAdapter', () => { test('lists every key type across pages and searches the alternative names too', async () => { const {adapter, sent} = adapterWith((command) => { if (!(command instanceof ListCertificatesCommand)) return {} return command.input.NextToken ? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]} : {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'} }) const all = await adapter.list() expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([ [ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'], ['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'], ]) // Without Includes, AWS leaves EC certificates out of the list. expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1') expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID]) }) test('describes a certificate with its users and the DNS records validation asks for', async () => { const {adapter, sent} = adapterWith(() => ({Certificate: detail})) const certificate = await adapter.get(ID) expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN) expect(certificate?.metadata).toMatchObject({ inUse: 'Yes', inUseBy: detail.InUseBy, exportable: true, issuer: 'CN=Floci Local CA', notAfter: '2027-10-25T16:28:12.000Z', domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}], }) }) test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => { const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') }) expect(await adapter.get(ID)).toBeNull() await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError) expect(sent).toHaveLength(1) }) test('requests a certificate for the domain and its extra names', async () => { const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail}) const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}}) expect(created.id).toBe(ID) expect((sent[0] as RequestCertificateCommand).input).toEqual({ DomainName: 'notes.thecloud.localhost', SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'], ValidationMethod: 'DNS', KeyAlgorithm: 'EC_prime256v1', Options: {Export: 'ENABLED'}, }) }) test('refuses a request ACM would reject before calling it', async () => { const {adapter, sent} = adapterWith(() => ({})) await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name') await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError) expect(sent).toHaveLength(0) }) test('imports PEM text as bytes, with the chain when given', async () => { const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}}) const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----' const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----' const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}}) expect(created.metadata.certificateType).toBe('Imported') const input = (sent[0] as ImportCertificateCommand).input expect(new TextDecoder().decode(input.Certificate)).toBe(certificate) expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey) expect(input.CertificateChain).toBeDefined() await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text') }) test('downloads, exports and revokes by the rebuilt ARN', async () => { const {adapter, sent} = adapterWith((command) => { if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'} if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'} if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN} return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}} }) expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'}) expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'}) expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame') const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED') expect(revoked.status).toBe('REVOKED') expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'}) await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError) await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError) await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError) }) test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => { const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') }) await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException') expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN) }) })