Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ This table is the source of truth for the current UI surface.
| Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. |
| Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. |
| Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. |
| Cloud Explorer / Secrets | No | Yes | Placeholder | Azure Key Vault list/create/delete/inspect through the schema-driven explorer. |
| Dedicated page / Secrets Manager | Yes | No | No | AWS-only page outside Cloud Explorer. |

Visible placeholders in the current sidebar:
Expand Down Expand Up @@ -178,9 +179,9 @@ Current gaps:
</details>

<details>
<summary><strong>Secrets Manager</strong></summary>
<summary><strong>Secrets</strong></summary>

This is the only dedicated AWS page still outside Cloud Explorer.
AWS Secrets Manager remains a dedicated page outside Cloud Explorer.

- List secrets.
- Inspect metadata.
Expand All @@ -189,10 +190,18 @@ This is the only dedicated AWS page still outside Cloud Explorer.
- Update values.
- Delete secrets, including force delete.

Azure Key Vault uses the shared Cloud Explorer:

- List and filter secret metadata without exposing values.
- Create secrets with optional content type.
- Inspect normalized metadata.
- Soft-delete secrets.

Current gaps:

- Not migrated into the Cloud Explorer contract yet.
- No Azure or GCP secret adapter yet.
- AWS Secrets Manager is not migrated into the Cloud Explorer contract yet.
- Key Vault value retrieval, versioning, recovery, and purge workflows are not exposed yet.
- No GCP Secret Manager adapter yet.

</details>

Expand Down
170 changes: 170 additions & 0 deletions packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {describe, expect, test} from 'bun:test'
import {AzureKeyVaultAdapter} from './AzureKeyVaultAdapter'
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'

interface RecordedCall {
path: string
init: RequestInit
options: AzureRuntimeFetchOptions
}

describe('AzureKeyVaultAdapter', () => {
test('lists and filters Key Vault secrets without exposing values', async () => {
const adapter = new AzureKeyVaultAdapter(testClient({
'/devstoreaccount1-keyvault/secrets?api-version=7.4': {
value: [
secretRecord('database-password', 'v1', {env: 'test'}),
secretRecord('api-token', 'v2'),
],
nextLink: null,
},
}))

await expect(adapter.list({search: 'database'})).resolves.toEqual([{
id: 'database-password',
name: 'database-password',
cloud: 'azure',
service: 'secrets',
type: 'secret',
region: null,
createdAt: '2026-05-20T20:00:00.000Z',
status: 'enabled',
version: 'v1',
metadata: {
provider: 'azure',
secretsService: 'key-vault',
vaultAccount: 'devstoreaccount1',
contentType: 'text/plain',
updatedAt: '2026-05-20T20:00:01.000Z',
expiresAt: null,
notBefore: null,
recoveryLevel: 'Purgeable',
recoverableDays: 7,
tags: [{key: 'env', value: 'test'}],
},
}])
})

test('gets a secret and omits its value from normalized metadata', async () => {
const adapter = new AzureKeyVaultAdapter(testClient({
'/devstoreaccount1-keyvault/secrets/database-password?api-version=7.4': {
...secretRecord('database-password', 'v1'),
value: 'super-secret',
},
}))

const resource = await adapter.get('database-password')

expect(resource).toMatchObject({id: 'database-password', version: 'v1'})
expect(resource?.metadata).not.toHaveProperty('value')
})

test('creates secrets through the Azure Key Vault data-plane contract', async () => {
const calls: RecordedCall[] = []
const adapter = new AzureKeyVaultAdapter(testClient({
'/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {
...secretRecord('api-key', 'created-version'),
value: 'abc123',
contentType: 'application/json',
},
}, calls))

const created = await adapter.create({values: {
secretName: 'api-key',
secretValue: 'abc123',
contentType: 'application/json',
}})

expect(created).toMatchObject({id: 'api-key', version: 'created-version'})
expect(calls).toHaveLength(1)
expect(calls[0].path).toBe('/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4')
expect(calls[0].init.method).toBe('PUT')
expect(calls[0].init.headers).toMatchObject({
authorization: 'Bearer floci-ui',
'content-type': 'application/json',
})
expect(JSON.parse(String(calls[0].init.body))).toEqual({
value: 'abc123',
contentType: 'application/json',
})
})

test('deletes secrets using soft-delete endpoint', async () => {
const calls: RecordedCall[] = []
const adapter = new AzureKeyVaultAdapter(testClient({
'/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {},
}, calls))

await adapter.delete('api-key')

expect(calls).toHaveLength(1)
expect(calls[0].init.method).toBe('DELETE')
expect(calls[0].init.headers).toMatchObject({authorization: 'Bearer floci-ui'})
})

test('normalizes missing secrets to null and missing lists to empty', async () => {
const adapter = new AzureKeyVaultAdapter(testClient({}))

await expect(adapter.get('missing')).resolves.toBeNull()
await expect(adapter.list()).resolves.toEqual([])
})

test('validates required inputs before calling the runtime', async () => {
const calls: RecordedCall[] = []
const adapter = new AzureKeyVaultAdapter(testClient({}, calls))

await expect(adapter.create({values: {secretName: 'not valid', secretValue: 'value'}}))
.rejects.toThrow('Use a valid Key Vault secret name')
await expect(adapter.create({values: {secretName: 'valid-name', secretValue: ''}}))
.rejects.toThrow('secretValue is required')
expect(calls).toHaveLength(0)
})

test('exposes Azure Key Vault CRUD capabilities in its schema', () => {
const schema = new AzureKeyVaultAdapter(testClient({})).schema()

expect(schema.service).toBe('secrets')
expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect'])
expect(schema.fields.find((field) => field.name === 'secretValue')?.type).toBe('password')
const namePattern = schema.fields.find((field) => field.name === 'secretName')?.validation?.pattern
expect(() => new RegExp(namePattern ?? '', 'v')).not.toThrow()
})
})

function secretRecord(name: string, version: string, tags: Record<string, string> = {}) {
return {
id: `https://devstoreaccount1.vault.azure.net/secrets/${name}/${version}`,
attributes: {
enabled: true,
created: 1779307200,
updated: 1779307201,
exp: null,
nbf: null,
recoveryLevel: 'Purgeable',
recoverableDays: 7,
},
contentType: 'text/plain',
tags,
}
}

function testClient(
responses: Record<string, unknown>,
calls: RecordedCall[] = [],
): AzureRuntimeClient {
return {
endpoint: 'http://localhost:4577',
accountName: 'devstoreaccount1',
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
calls.push({path, init, options})
if (!(path in responses)) {
if (options.emptyOnNotFound) return null
throw new Error(`Unexpected Key Vault path: ${path}`)
}
return new Response(JSON.stringify(responses[path]), {
status: 200,
headers: {'content-type': 'application/json'},
})
},
}
}
Loading