6e0bb98844
CI / checks (pull_request) Successful in 27m28s
Request public certificates (optionally exportable) or import your own, see each domain's DNS validation records and what uses a certificate, download the certificate and chain, export an exportable one with its encrypted key, and revoke it. ListCertificates asks for every key type, since AWS otherwise leaves EC certificates out.
165 lines
9.2 KiB
TypeScript
165 lines
9.2 KiB
TypeScript
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<string, unknown>
|
|
|
|
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
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<SendResult>) {
|
|
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)
|
|
})
|
|
})
|