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
223 lines
6.7 KiB
TypeScript
223 lines
6.7 KiB
TypeScript
/**
|
|
* Tests for API key authentication utilities.
|
|
*
|
|
* Tests cover:
|
|
* - API key format detection (legacy vs encrypted)
|
|
* - Authentication against stored keys
|
|
* - Key encryption and decryption
|
|
* - Display formatting
|
|
* - Edge cases
|
|
*/
|
|
|
|
import { randomBytes } from 'crypto'
|
|
import { createEncryptedApiKey, createLegacyApiKey } from '@sim/testing'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
const cryptoMock = vi.hoisted(() => ({
|
|
isEncryptedApiKeyFormat: (key: string) => key.startsWith('sk-sim-'),
|
|
isLegacyApiKeyFormat: (key: string) => key.startsWith('sim_') && !key.startsWith('sk-sim-'),
|
|
generateApiKey: () => `sim_${randomBytes(24).toString('base64url')}`,
|
|
generateEncryptedApiKey: () => `sk-sim-${randomBytes(24).toString('base64url')}`,
|
|
encryptApiKey: async (apiKey: string) => ({
|
|
encrypted: `mock-iv:${Buffer.from(apiKey).toString('hex')}:mock-tag`,
|
|
iv: 'mock-iv',
|
|
}),
|
|
decryptApiKey: async (encryptedValue: string) => {
|
|
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
|
|
return { decrypted: encryptedValue }
|
|
}
|
|
const parts = encryptedValue.split(':')
|
|
const hexPart = parts[1]
|
|
return { decrypted: Buffer.from(hexPart, 'hex').toString('utf8') }
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/api-key/crypto', () => cryptoMock)
|
|
|
|
import {
|
|
formatApiKeyForDisplay,
|
|
getApiKeyLast4,
|
|
isEncryptedKey,
|
|
isValidApiKeyFormat,
|
|
} from '@/lib/api-key/auth'
|
|
|
|
const { generateApiKey, generateEncryptedApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat } =
|
|
cryptoMock
|
|
|
|
describe('isEncryptedKey', () => {
|
|
it('should detect encrypted storage format (iv:encrypted:authTag)', () => {
|
|
const encryptedStorage = 'abc123:encrypted-data:tag456'
|
|
expect(isEncryptedKey(encryptedStorage)).toBe(true)
|
|
})
|
|
|
|
it('should detect plain text storage (no colons)', () => {
|
|
const plainKey = 'sim_abcdef123456'
|
|
expect(isEncryptedKey(plainKey)).toBe(false)
|
|
})
|
|
|
|
it('should detect plain text with single colon', () => {
|
|
const singleColon = 'part1:part2'
|
|
expect(isEncryptedKey(singleColon)).toBe(false)
|
|
})
|
|
|
|
it('should detect encrypted format with exactly 3 parts', () => {
|
|
const threeParts = 'iv:data:tag'
|
|
expect(isEncryptedKey(threeParts)).toBe(true)
|
|
})
|
|
|
|
it('should reject format with more than 3 parts', () => {
|
|
const fourParts = 'a:b:c:d'
|
|
expect(isEncryptedKey(fourParts)).toBe(false)
|
|
})
|
|
|
|
it('should reject empty string', () => {
|
|
expect(isEncryptedKey('')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isEncryptedApiKeyFormat (key prefix)', () => {
|
|
it('should detect sk-sim- prefix as encrypted format', () => {
|
|
const { key } = createEncryptedApiKey()
|
|
expect(isEncryptedApiKeyFormat(key)).toBe(true)
|
|
})
|
|
|
|
it('should not detect sim_ prefix as encrypted format', () => {
|
|
const { key } = createLegacyApiKey()
|
|
expect(isEncryptedApiKeyFormat(key)).toBe(false)
|
|
})
|
|
|
|
it('should not detect random string as encrypted format', () => {
|
|
expect(isEncryptedApiKeyFormat('random-string')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isLegacyApiKeyFormat', () => {
|
|
it('should detect sim_ prefix as legacy format', () => {
|
|
const { key } = createLegacyApiKey()
|
|
expect(isLegacyApiKeyFormat(key)).toBe(true)
|
|
})
|
|
|
|
it('should not detect sk-sim- prefix as legacy format', () => {
|
|
const { key } = createEncryptedApiKey()
|
|
expect(isLegacyApiKeyFormat(key)).toBe(false)
|
|
})
|
|
|
|
it('should not detect random string as legacy format', () => {
|
|
expect(isLegacyApiKeyFormat('random-string')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isValidApiKeyFormat', () => {
|
|
it('should accept valid length keys', () => {
|
|
expect(isValidApiKeyFormat(`sim_${'a'.repeat(20)}`)).toBe(true)
|
|
})
|
|
|
|
it('should reject too short keys', () => {
|
|
expect(isValidApiKeyFormat('short')).toBe(false)
|
|
})
|
|
|
|
it('should reject too long keys (>200 chars)', () => {
|
|
expect(isValidApiKeyFormat('a'.repeat(201))).toBe(false)
|
|
})
|
|
|
|
it('should accept keys at boundary (11 chars)', () => {
|
|
expect(isValidApiKeyFormat('a'.repeat(11))).toBe(true)
|
|
})
|
|
|
|
it('should reject keys at boundary (10 chars)', () => {
|
|
expect(isValidApiKeyFormat('a'.repeat(10))).toBe(false)
|
|
})
|
|
|
|
it('should reject non-string input', () => {
|
|
expect(isValidApiKeyFormat(null as any)).toBe(false)
|
|
expect(isValidApiKeyFormat(undefined as any)).toBe(false)
|
|
expect(isValidApiKeyFormat(123 as any)).toBe(false)
|
|
})
|
|
|
|
it('should reject empty string', () => {
|
|
expect(isValidApiKeyFormat('')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getApiKeyLast4', () => {
|
|
it('should return last 4 characters of key', () => {
|
|
expect(getApiKeyLast4('sim_abcdefghijklmnop')).toBe('mnop')
|
|
})
|
|
|
|
it('should return last 4 characters of encrypted format key', () => {
|
|
expect(getApiKeyLast4('sk-sim-abcdefghijkl')).toBe('ijkl')
|
|
})
|
|
|
|
it('should return entire key if less than 4 chars', () => {
|
|
expect(getApiKeyLast4('abc')).toBe('abc')
|
|
})
|
|
|
|
it('should handle exactly 4 chars', () => {
|
|
expect(getApiKeyLast4('abcd')).toBe('abcd')
|
|
})
|
|
})
|
|
|
|
describe('formatApiKeyForDisplay', () => {
|
|
it('should format encrypted format key with sk-sim- prefix', () => {
|
|
const key = 'sk-sim-abcdefghijklmnopqrstuvwx'
|
|
const formatted = formatApiKeyForDisplay(key)
|
|
expect(formatted).toBe('sk-sim-...uvwx')
|
|
})
|
|
|
|
it('should format legacy key with sim_ prefix', () => {
|
|
const key = 'sim_abcdefghijklmnopqrstuvwx'
|
|
const formatted = formatApiKeyForDisplay(key)
|
|
expect(formatted).toBe('sim_...uvwx')
|
|
})
|
|
|
|
it('should format unknown format key with just ellipsis', () => {
|
|
const key = 'custom-key-format-abcd'
|
|
const formatted = formatApiKeyForDisplay(key)
|
|
expect(formatted).toBe('...abcd')
|
|
})
|
|
|
|
it('should show last 4 characters correctly', () => {
|
|
const key = 'sk-sim-xxxxxxxxxxxxxxxxr6AA'
|
|
const formatted = formatApiKeyForDisplay(key)
|
|
expect(formatted).toContain('r6AA')
|
|
})
|
|
})
|
|
|
|
describe('generateApiKey', () => {
|
|
it('should generate key with sim_ prefix', () => {
|
|
const key = generateApiKey()
|
|
expect(key).toMatch(/^sim_/)
|
|
})
|
|
|
|
it('should generate unique keys', () => {
|
|
const key1 = generateApiKey()
|
|
const key2 = generateApiKey()
|
|
expect(key1).not.toBe(key2)
|
|
})
|
|
|
|
it('should generate key of valid length', () => {
|
|
const key = generateApiKey()
|
|
expect(key.length).toBeGreaterThan(10)
|
|
expect(key.length).toBeLessThan(100)
|
|
})
|
|
})
|
|
|
|
describe('generateEncryptedApiKey', () => {
|
|
it('should generate key with sk-sim- prefix', () => {
|
|
const key = generateEncryptedApiKey()
|
|
expect(key).toMatch(/^sk-sim-/)
|
|
})
|
|
|
|
it('should generate unique keys', () => {
|
|
const key1 = generateEncryptedApiKey()
|
|
const key2 = generateEncryptedApiKey()
|
|
expect(key1).not.toBe(key2)
|
|
})
|
|
|
|
it('should generate key of valid length', () => {
|
|
const key = generateEncryptedApiKey()
|
|
expect(key.length).toBeGreaterThan(10)
|
|
expect(key.length).toBeLessThan(100)
|
|
})
|
|
})
|