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 /** Minimal KMSClient stub that records the commands it was sent. */ function stubKms(handler: (command: object) => SendResult | Promise) { 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 = {}) { 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) }) })