d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { mockOrderBy, mockDecryptSecret } = vi.hoisted(() => ({
|
|
mockOrderBy: vi.fn(),
|
|
mockDecryptSecret: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => ({
|
|
db: {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => ({ orderBy: mockOrderBy })),
|
|
})),
|
|
})),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/core/security/encryption', () => ({
|
|
decryptSecret: mockDecryptSecret,
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/api-keys', () => ({
|
|
getRotatingApiKey: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env', () => ({
|
|
env: {},
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env-flags', () => ({
|
|
isHosted: false,
|
|
}))
|
|
|
|
vi.mock('@/providers/models', () => ({
|
|
getProviderFileAttachment: vi
|
|
.fn()
|
|
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
|
|
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
|
|
getHostedModels: vi.fn(() => []),
|
|
}))
|
|
|
|
vi.mock('@/providers/utils', () => ({
|
|
PROVIDER_PLACEHOLDER_KEY: 'placeholder',
|
|
}))
|
|
|
|
vi.mock('@/stores/providers/store', () => ({
|
|
useProvidersStore: { getState: vi.fn() },
|
|
}))
|
|
|
|
import { getBYOKKey } from '@/lib/api-key/byok'
|
|
|
|
/**
|
|
* Rotation counters in the module under test are keyed by
|
|
* `${workspaceId}:${providerId}` and persist for the process lifetime, so
|
|
* each test uses a unique workspace id to start from a fresh cursor.
|
|
*/
|
|
let testIndex = 0
|
|
const uniqueWorkspaceId = () => `workspace-${++testIndex}`
|
|
|
|
const storedKey = (id: string) => ({ id, encryptedApiKey: `encrypted-${id}` })
|
|
|
|
describe('getBYOKKey', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockOrderBy.mockResolvedValue([])
|
|
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
|
|
decrypted: encrypted.replace('encrypted-', 'decrypted-'),
|
|
}))
|
|
})
|
|
|
|
it('returns null when no workspaceId is provided', async () => {
|
|
expect(await getBYOKKey(undefined, 'openai')).toBeNull()
|
|
expect(await getBYOKKey(null, 'openai')).toBeNull()
|
|
})
|
|
|
|
it('returns null when the workspace has no keys for the provider', async () => {
|
|
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
|
|
})
|
|
|
|
it('returns the same key on every call when only one key is stored', async () => {
|
|
const workspaceId = uniqueWorkspaceId()
|
|
mockOrderBy.mockResolvedValue([storedKey('key-1')])
|
|
|
|
for (let call = 0; call < 3; call++) {
|
|
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
|
|
apiKey: 'decrypted-key-1',
|
|
isBYOK: true,
|
|
})
|
|
}
|
|
})
|
|
|
|
it('round-robins across multiple keys in creation order', async () => {
|
|
const workspaceId = uniqueWorkspaceId()
|
|
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2'), storedKey('key-3')])
|
|
|
|
const apiKeys = []
|
|
for (let call = 0; call < 4; call++) {
|
|
const result = await getBYOKKey(workspaceId, 'openai')
|
|
apiKeys.push(result?.apiKey)
|
|
}
|
|
|
|
expect(apiKeys).toEqual([
|
|
'decrypted-key-1',
|
|
'decrypted-key-2',
|
|
'decrypted-key-3',
|
|
'decrypted-key-1',
|
|
])
|
|
})
|
|
|
|
it('reads the key list fresh from the database on every call', async () => {
|
|
const workspaceId = uniqueWorkspaceId()
|
|
mockOrderBy.mockResolvedValue([storedKey('key-1')])
|
|
|
|
await getBYOKKey(workspaceId, 'openai')
|
|
await getBYOKKey(workspaceId, 'openai')
|
|
await getBYOKKey(workspaceId, 'openai')
|
|
|
|
expect(mockOrderBy).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('tracks rotation independently per provider within a workspace', async () => {
|
|
const workspaceId = uniqueWorkspaceId()
|
|
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
|
|
|
|
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-1')
|
|
expect((await getBYOKKey(workspaceId, 'anthropic'))?.apiKey).toBe('decrypted-key-1')
|
|
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-2')
|
|
})
|
|
|
|
it('skips a key that fails to decrypt and returns the next one', async () => {
|
|
const workspaceId = uniqueWorkspaceId()
|
|
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
|
|
mockDecryptSecret.mockImplementation(async (encrypted: string) => {
|
|
if (encrypted === 'encrypted-key-1') {
|
|
throw new Error('corrupt ciphertext')
|
|
}
|
|
return { decrypted: encrypted.replace('encrypted-', 'decrypted-') }
|
|
})
|
|
|
|
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
|
|
apiKey: 'decrypted-key-2',
|
|
isBYOK: true,
|
|
})
|
|
})
|
|
|
|
it('returns null when every key fails to decrypt', async () => {
|
|
const workspaceId = uniqueWorkspaceId()
|
|
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
|
|
mockDecryptSecret.mockRejectedValue(new Error('corrupt ciphertext'))
|
|
|
|
expect(await getBYOKKey(workspaceId, 'openai')).toBeNull()
|
|
})
|
|
|
|
it('returns null when the keys query throws', async () => {
|
|
mockOrderBy.mockRejectedValue(new Error('database unavailable'))
|
|
|
|
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
|
|
})
|
|
})
|