462 lines
19 KiB
TypeScript
462 lines
19 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
CreateKeyCommand,
|
|
DecryptCommand,
|
|
DescribeKeyCommand,
|
|
EncryptCommand,
|
|
type KMSClient,
|
|
ListKeysCommand,
|
|
ListResourceTagsCommand,
|
|
ScheduleKeyDeletionCommand,
|
|
TagResourceCommand,
|
|
UntagResourceCommand,
|
|
} from '@aws-sdk/client-kms'
|
|
import {AwsKmsAdapter} from './AwsKmsAdapter'
|
|
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
|
|
|
type SendResult = Record<string, unknown>
|
|
|
|
/** Minimal KMSClient stub that records the commands it was sent. */
|
|
function stubKms(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
const sent: object[] = []
|
|
const client = {
|
|
async send(command: object) {
|
|
sent.push(command)
|
|
return handler(command)
|
|
},
|
|
} as unknown as KMSClient
|
|
return {client, sent}
|
|
}
|
|
|
|
const KEY_ID = '68543643-23b6-4037-a59b-4d43d5d308f1'
|
|
const KEY_ARN = `arn:aws:kms:us-east-1:000000000000:key/${KEY_ID}`
|
|
|
|
const keyMetadata = {
|
|
AWSAccountId: '000000000000',
|
|
KeyId: KEY_ID,
|
|
Arn: KEY_ARN,
|
|
CreationDate: new Date('2026-07-28T10:00:00.000Z'),
|
|
Enabled: true,
|
|
Description: 'payments signing key',
|
|
KeyUsage: 'ENCRYPT_DECRYPT',
|
|
KeyState: 'Enabled',
|
|
Origin: 'AWS_KMS',
|
|
KeyManager: 'CUSTOMER',
|
|
KeySpec: 'SYMMETRIC_DEFAULT',
|
|
EncryptionAlgorithms: ['SYMMETRIC_DEFAULT'],
|
|
}
|
|
|
|
/** Routes each command to the response the real runtime gives for it. */
|
|
function runtimeStub(overrides: Record<string, SendResult> = {}) {
|
|
return stubKms((command) => {
|
|
if (command instanceof ListKeysCommand) {
|
|
return overrides.list ?? {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]}
|
|
}
|
|
if (command instanceof DescribeKeyCommand) {
|
|
return overrides.describe ?? {KeyMetadata: keyMetadata}
|
|
}
|
|
if (command instanceof ListResourceTagsCommand) {
|
|
return overrides.tags ?? {Tags: []}
|
|
}
|
|
return {}
|
|
})
|
|
}
|
|
|
|
describe('AwsKmsAdapter', () => {
|
|
test('identifies itself as the AWS KMS adapter', () => {
|
|
const adapter = new AwsKmsAdapter(runtimeStub().client)
|
|
|
|
expect(adapter.cloud).toBe('aws')
|
|
expect(adapter.service).toBe('kms')
|
|
expect(adapter.schema().displayName).toBe('AWS KMS')
|
|
})
|
|
|
|
test('lists keys by fanning out DescribeKey over the thin ListKeys result', async () => {
|
|
// ListKeys returns only id and ARN, so every displayed column depends on
|
|
// the per-key DescribeKey call.
|
|
const {client, sent} = runtimeStub()
|
|
const [resource] = await new AwsKmsAdapter(client).list()
|
|
|
|
expect(sent[0]).toBeInstanceOf(ListKeysCommand)
|
|
expect(sent[1]).toBeInstanceOf(DescribeKeyCommand)
|
|
expect(resource).toMatchObject({
|
|
id: KEY_ID,
|
|
name: KEY_ID,
|
|
cloud: 'aws',
|
|
service: 'kms',
|
|
type: 'key',
|
|
region: 'us-east-1',
|
|
status: 'Enabled',
|
|
createdAt: '2026-07-28T10:00:00.000Z',
|
|
})
|
|
expect(resource?.metadata).toMatchObject({
|
|
arn: KEY_ARN,
|
|
description: 'payments signing key',
|
|
keyUsage: 'ENCRYPT_DECRYPT',
|
|
keySpec: 'SYMMETRIC_DEFAULT',
|
|
keyManager: 'CUSTOMER',
|
|
origin: 'AWS_KMS',
|
|
enabled: true,
|
|
})
|
|
})
|
|
|
|
test('keeps keys that are pending deletion in the list', async () => {
|
|
// ScheduleKeyDeletion is a soft delete and real AWS keeps listing the key,
|
|
// so hiding it here would misreport the account.
|
|
const {client} = runtimeStub({
|
|
describe: {KeyMetadata: {...keyMetadata, KeyState: 'PendingDeletion', Enabled: false}},
|
|
})
|
|
const [resource] = await new AwsKmsAdapter(client).list()
|
|
|
|
expect(resource?.status).toBe('PendingDeletion')
|
|
})
|
|
|
|
test('surfaces key tags under metadata', async () => {
|
|
const {client} = runtimeStub({tags: {Tags: [{TagKey: 'env', TagValue: 'prod'}]}})
|
|
const [resource] = await new AwsKmsAdapter(client).list()
|
|
|
|
expect(resource?.metadata.tags).toEqual([{key: 'env', value: 'prod'}])
|
|
})
|
|
|
|
test('still lists keys when the tag lookup fails', async () => {
|
|
// list() builds every row through Promise.all, so an unisolated tag
|
|
// failure would reject the whole view and show nothing, when the key
|
|
// metadata was available all along.
|
|
const {client} = stubKms((command) => {
|
|
if (command instanceof ListKeysCommand) return {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]}
|
|
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
|
|
throw Object.assign(new Error('Rate exceeded'), {
|
|
name: 'ThrottlingException',
|
|
$metadata: {httpStatusCode: 400},
|
|
})
|
|
})
|
|
|
|
const [resource] = await new AwsKmsAdapter(client).list()
|
|
|
|
expect(resource?.id).toBe(KEY_ID)
|
|
expect(resource?.metadata.tags).toEqual([])
|
|
// Degraded, not silent.
|
|
expect(resource?.metadata.tagsUnavailable).toBe(true)
|
|
})
|
|
|
|
test('does not claim tags are unavailable when a key simply has none', async () => {
|
|
const {client} = runtimeStub()
|
|
const [resource] = await new AwsKmsAdapter(client).list()
|
|
|
|
expect(resource?.metadata.tags).toEqual([])
|
|
expect(resource?.metadata.tagsUnavailable).toBeUndefined()
|
|
})
|
|
|
|
test('filters the list by key id or description', async () => {
|
|
// The name is an opaque uuid, so a search that ignored the description
|
|
// would be unusable.
|
|
const adapter = new AwsKmsAdapter(runtimeStub().client)
|
|
|
|
await expect(adapter.list({search: '68543643'})).resolves.toHaveLength(1)
|
|
await expect(adapter.list({search: 'payments'})).resolves.toHaveLength(1)
|
|
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
|
|
})
|
|
|
|
test('returns null when a key does not exist', async () => {
|
|
const {client} = stubKms(() => {
|
|
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
|
})
|
|
await expect(new AwsKmsAdapter(client).get('missing')).resolves.toBeNull()
|
|
})
|
|
|
|
/**
|
|
* Real KMS is one of the AWS services that does not use 404 for not-found:
|
|
* it answers `NotFoundException` with HTTP 400. The local runtime sends 404,
|
|
* so a status-only check passes locally and turns a missing key into a 502
|
|
* against real AWS.
|
|
*/
|
|
test('returns null for the real KMS not-found shape, which carries HTTP 400', async () => {
|
|
const {client} = stubKms(() => {
|
|
throw Object.assign(new Error('Key ARN does not exist'), {
|
|
name: 'NotFoundException',
|
|
$metadata: {httpStatusCode: 400},
|
|
})
|
|
})
|
|
await expect(new AwsKmsAdapter(client).get('missing')).resolves.toBeNull()
|
|
})
|
|
|
|
test('rethrows a non-404 failure from get', async () => {
|
|
const {client} = stubKms(() => {
|
|
throw Object.assign(new Error('AccessDenied'), {$metadata: {httpStatusCode: 403}})
|
|
})
|
|
await expect(new AwsKmsAdapter(client).get(KEY_ID)).rejects.toThrow('AccessDenied')
|
|
})
|
|
|
|
test('inspects a key through DescribeKey', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
const resource = await new AwsKmsAdapter(client).get(KEY_ID)
|
|
|
|
expect(sent[0]).toBeInstanceOf(DescribeKeyCommand)
|
|
expect((sent[0] as DescribeKeyCommand).input.KeyId).toBe(KEY_ID)
|
|
expect(resource?.id).toBe(KEY_ID)
|
|
})
|
|
|
|
test('creates a key with the requested description and usage', async () => {
|
|
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
|
|
const resource = await new AwsKmsAdapter(client).create({
|
|
values: {description: 'payments signing key', keyUsage: 'ENCRYPT_DECRYPT'},
|
|
})
|
|
|
|
const command = sent[0] as CreateKeyCommand
|
|
expect(command).toBeInstanceOf(CreateKeyCommand)
|
|
expect(command.input.Description).toBe('payments signing key')
|
|
expect(command.input.KeyUsage).toBe('ENCRYPT_DECRYPT')
|
|
expect(resource.id).toBe(KEY_ID)
|
|
})
|
|
|
|
test('defaults key usage and spec when they are omitted', async () => {
|
|
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
|
|
await new AwsKmsAdapter(client).create({values: {}})
|
|
|
|
const command = sent[0] as CreateKeyCommand
|
|
expect(command.input.KeyUsage).toBe('ENCRYPT_DECRYPT')
|
|
expect(command.input.KeySpec).toBe('SYMMETRIC_DEFAULT')
|
|
})
|
|
|
|
describe('key usage and spec compatibility', () => {
|
|
// The local runtime accepts SIGN_VERIFY with SYMMETRIC_DEFAULT and
|
|
// ENCRYPT_DECRYPT with ECC_NIST_P256 and returns 200, but real KMS rejects
|
|
// both. Only these tests can hold the line, so they encode the provider's
|
|
// rules rather than the emulator's.
|
|
test('defaults the spec to one that is valid for the chosen usage', async () => {
|
|
const cases: Array<[string, string]> = [
|
|
['ENCRYPT_DECRYPT', 'SYMMETRIC_DEFAULT'],
|
|
['SIGN_VERIFY', 'RSA_2048'],
|
|
['GENERATE_VERIFY_MAC', 'HMAC_256'],
|
|
]
|
|
|
|
for (const [keyUsage, expectedSpec] of cases) {
|
|
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
|
|
await new AwsKmsAdapter(client).create({values: {keyUsage}})
|
|
|
|
expect(String((sent[0] as CreateKeyCommand).input.KeySpec)).toBe(expectedSpec)
|
|
}
|
|
})
|
|
|
|
test('rejects a spec that the chosen usage cannot use', async () => {
|
|
const cases: Array<[string, string]> = [
|
|
['SIGN_VERIFY', 'SYMMETRIC_DEFAULT'],
|
|
['ENCRYPT_DECRYPT', 'ECC_NIST_P256'],
|
|
['ENCRYPT_DECRYPT', 'HMAC_256'],
|
|
['GENERATE_VERIFY_MAC', 'RSA_2048'],
|
|
]
|
|
|
|
for (const [keyUsage, keySpec] of cases) {
|
|
// Returns valid metadata, so a create that reaches the runtime
|
|
// succeeds — the only way this rejects is the compatibility check.
|
|
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
|
|
const adapter = new AwsKmsAdapter(client)
|
|
|
|
await expect(adapter.create({values: {keyUsage, keySpec}})).rejects.toThrow(ValidationError)
|
|
expect(sent, `${keyUsage}/${keySpec} must be rejected before reaching KMS`).toHaveLength(0)
|
|
}
|
|
})
|
|
|
|
test('accepts every pair the provider considers valid', async () => {
|
|
const cases: Array<[string, string]> = [
|
|
['ENCRYPT_DECRYPT', 'SYMMETRIC_DEFAULT'],
|
|
['ENCRYPT_DECRYPT', 'RSA_2048'],
|
|
['ENCRYPT_DECRYPT', 'RSA_4096'],
|
|
['SIGN_VERIFY', 'RSA_2048'],
|
|
['SIGN_VERIFY', 'RSA_4096'],
|
|
['SIGN_VERIFY', 'ECC_NIST_P256'],
|
|
['GENERATE_VERIFY_MAC', 'HMAC_256'],
|
|
]
|
|
|
|
for (const [keyUsage, keySpec] of cases) {
|
|
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
|
|
await new AwsKmsAdapter(client).create({values: {keyUsage, keySpec}})
|
|
|
|
expect(String((sent[0] as CreateKeyCommand).input.KeySpec)).toBe(keySpec)
|
|
}
|
|
})
|
|
})
|
|
|
|
test('follows the tag pages so a heavily tagged key is not truncated', async () => {
|
|
let tagCall = 0
|
|
const {client} = stubKms((command) => {
|
|
if (command instanceof ListKeysCommand) return {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]}
|
|
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
|
|
tagCall += 1
|
|
if (tagCall === 1) {
|
|
return {Tags: [{TagKey: 'a', TagValue: '1'}], Truncated: true, NextMarker: 'tag-page-2'}
|
|
}
|
|
return {Tags: [{TagKey: 'b', TagValue: '2'}], Truncated: false}
|
|
})
|
|
|
|
const [resource] = await new AwsKmsAdapter(client).list()
|
|
|
|
expect(resource?.metadata.tags).toEqual([
|
|
{key: 'a', value: '1'},
|
|
{key: 'b', value: '2'},
|
|
])
|
|
})
|
|
|
|
test('rejects a key usage the schema does not offer', async () => {
|
|
const adapter = new AwsKmsAdapter(stubKms(() => ({})).client)
|
|
|
|
await expect(adapter.create({values: {keyUsage: 'MINE_BITCOIN'}})).rejects.toThrow(ValidationError)
|
|
})
|
|
|
|
test('rejects a description longer than the KMS limit', async () => {
|
|
const adapter = new AwsKmsAdapter(stubKms(() => ({})).client)
|
|
|
|
await expect(adapter.create({values: {description: 'x'.repeat(8193)}})).rejects.toThrow(ValidationError)
|
|
})
|
|
|
|
test('encrypts symmetric plaintext after validating the selected key', async () => {
|
|
const ciphertext = new Uint8Array([9, 8, 7])
|
|
const {client, sent} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
|
|
if (command instanceof EncryptCommand) {
|
|
return {CiphertextBlob: ciphertext, KeyId: KEY_ARN, EncryptionAlgorithm: 'SYMMETRIC_DEFAULT'}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
const result = await new AwsKmsAdapter(client).encrypt(KEY_ID, {
|
|
plaintext: new TextEncoder().encode('hello'),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
encryptionContext: {purpose: 'test'},
|
|
})
|
|
|
|
expect(sent[0]).toBeInstanceOf(DescribeKeyCommand)
|
|
const command = sent[1] as EncryptCommand
|
|
expect(command).toBeInstanceOf(EncryptCommand)
|
|
expect(command.input.KeyId).toBe(KEY_ID)
|
|
expect(command.input.EncryptionContext).toEqual({purpose: 'test'})
|
|
expect(new TextDecoder().decode(command.input.Plaintext)).toBe('hello')
|
|
expect(result).toEqual({
|
|
ciphertextBlob: ciphertext,
|
|
keyId: KEY_ARN,
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
})
|
|
})
|
|
|
|
test('rejects plaintext above the RSA OAEP limit before encrypting', async () => {
|
|
const {client, sent} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) {
|
|
return {KeyMetadata: {...keyMetadata, KeySpec: 'RSA_2048'}}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
|
|
plaintext: new Uint8Array(191),
|
|
encryptionAlgorithm: 'RSAES_OAEP_SHA_256',
|
|
})).rejects.toThrow('190 bytes or fewer')
|
|
expect(sent).toHaveLength(1)
|
|
})
|
|
|
|
test('rejects an encryption context for RSA keys', async () => {
|
|
const {client, sent} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) {
|
|
return {KeyMetadata: {...keyMetadata, KeySpec: 'RSA_4096'}}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
|
|
plaintext: new Uint8Array([1]),
|
|
encryptionAlgorithm: 'RSAES_OAEP_SHA_256',
|
|
encryptionContext: {secret: 'not-supported'},
|
|
})).rejects.toThrow('do not support encryptionContext')
|
|
expect(sent).toHaveLength(1)
|
|
})
|
|
|
|
test('rejects crypto operations for disabled keys', async () => {
|
|
const {client, sent} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) {
|
|
return {KeyMetadata: {...keyMetadata, Enabled: false, KeyState: 'Disabled'}}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
|
|
plaintext: new Uint8Array([1]),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
})).rejects.toThrow('must be enabled')
|
|
expect(sent).toHaveLength(1)
|
|
})
|
|
|
|
test('decrypts ciphertext with the selected key and context', async () => {
|
|
const plaintext = new TextEncoder().encode('round trip')
|
|
const {client, sent} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
|
|
if (command instanceof DecryptCommand) {
|
|
return {Plaintext: plaintext, KeyId: KEY_ARN, EncryptionAlgorithm: 'SYMMETRIC_DEFAULT'}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
const result = await new AwsKmsAdapter(client).decrypt(KEY_ID, {
|
|
ciphertextBlob: new Uint8Array([3, 2, 1]),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
encryptionContext: {purpose: 'test'},
|
|
})
|
|
|
|
const command = sent[1] as DecryptCommand
|
|
expect(command).toBeInstanceOf(DecryptCommand)
|
|
expect(command.input.CiphertextBlob).toEqual(new Uint8Array([3, 2, 1]))
|
|
expect(command.input.EncryptionContext).toEqual({purpose: 'test'})
|
|
expect(new TextDecoder().decode(result.plaintext)).toBe('round trip')
|
|
})
|
|
|
|
test('treats a missing provider ciphertext as a runtime contract failure', async () => {
|
|
const {client} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
|
|
return {}
|
|
})
|
|
|
|
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
|
|
plaintext: new Uint8Array([1]),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
})).rejects.toThrow(RuntimeError)
|
|
})
|
|
|
|
test('treats a missing provider plaintext as a runtime contract failure', async () => {
|
|
const {client} = stubKms((command) => {
|
|
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
|
|
return {}
|
|
})
|
|
|
|
await expect(new AwsKmsAdapter(client).decrypt(KEY_ID, {
|
|
ciphertextBlob: new Uint8Array([1]),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
})).rejects.toThrow(RuntimeError)
|
|
})
|
|
|
|
test('deletes a key by scheduling deletion with the shortest window', async () => {
|
|
const {client, sent} = stubKms(() => ({KeyId: KEY_ARN, DeletionDate: new Date()}))
|
|
await new AwsKmsAdapter(client).delete(KEY_ID)
|
|
|
|
const command = sent[0] as ScheduleKeyDeletionCommand
|
|
expect(command).toBeInstanceOf(ScheduleKeyDeletionCommand)
|
|
expect(command.input.KeyId).toBe(KEY_ID)
|
|
expect(command.input.PendingWindowInDays).toBe(7)
|
|
})
|
|
|
|
test('adds and removes tags in one update', async () => {
|
|
const {client, sent} = stubKms(() => ({}))
|
|
await new AwsKmsAdapter(client).updateTags(KEY_ID, {env: 'prod', stale: null})
|
|
|
|
const tagged = sent.find((c) => c instanceof TagResourceCommand) as TagResourceCommand
|
|
const untagged = sent.find((c) => c instanceof UntagResourceCommand) as UntagResourceCommand
|
|
|
|
expect(tagged.input.Tags).toEqual([{TagKey: 'env', TagValue: 'prod'}])
|
|
expect(untagged.input.TagKeys).toEqual(['stale'])
|
|
})
|
|
|
|
test('skips the tag calls it has nothing to send', async () => {
|
|
const {client, sent} = stubKms(() => ({}))
|
|
await new AwsKmsAdapter(client).updateTags(KEY_ID, {env: 'prod'})
|
|
|
|
expect(sent.some((c) => c instanceof UntagResourceCommand)).toBe(false)
|
|
})
|
|
})
|