diff --git a/src/modules/ai-runtime/credential-encryption.service.spec.ts b/src/modules/ai-runtime/credential-encryption.service.spec.ts new file mode 100644 index 0000000..0eaa2c9 --- /dev/null +++ b/src/modules/ai-runtime/credential-encryption.service.spec.ts @@ -0,0 +1,141 @@ +import { CredentialEncryptionService } from './credential-encryption.service'; + +describe('CredentialEncryptionService', () => { + let service: CredentialEncryptionService; + let mockConfig: any; + + beforeEach(() => { + mockConfig = { + get: jest.fn().mockReturnValue('test-encryption-key-32bytes!!'), + }; + service = new CredentialEncryptionService(mockConfig as any); + }); + + // ═══════════════════════════════════════════════════════════════════ + // encrypt / decrypt round-trip + // ═══════════════════════════════════════════════════════════════════ + + describe('encrypt ↔ decrypt', () => { + it('round-trip: encrypt then decrypt returns original plaintext', () => { + const plaintext = 'sk-test-api-key-12345678'; + const encrypted = service.encrypt(plaintext); + const decrypted = service.decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); + + it('produces different ciphertext for same plaintext (random IV)', () => { + const plaintext = 'sk-mytest'; + const c1 = service.encrypt(plaintext); + const c2 = service.encrypt(plaintext); + expect(c1).not.toBe(c2); + }); + + it('encrypted output is base64', () => { + const c = service.encrypt('hello'); + expect(() => Buffer.from(c, 'base64')).not.toThrow(); + }); + + it('handles empty string', () => { + const c = service.encrypt(''); + const d = service.decrypt(c); + expect(d).toBe(''); + }); + + it('handles unicode characters', () => { + const plaintext = '密钥测试-key-中文'; + const c = service.encrypt(plaintext); + const d = service.decrypt(c); + expect(d).toBe(plaintext); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // hash + // ═══════════════════════════════════════════════════════════════════ + + describe('hash', () => { + it('returns deterministic hex hash for same input', () => { + const h1 = service.hash('sk-abc'); + const h2 = service.hash('sk-abc'); + expect(h1).toBe(h2); + expect(h1).toHaveLength(64); // SHA-256 = 64 hex chars + }); + + it('produces different hashes for different inputs', () => { + const h1 = service.hash('sk-abc'); + const h2 = service.hash('sk-def'); + expect(h1).not.toBe(h2); + }); + + it('handles empty string', () => { + const h = service.hash(''); + expect(h).toHaveLength(64); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // mask + // ═══════════════════════════════════════════════════════════════════ + + describe('mask', () => { + it('masks long keys: first-4 + **** + last-4', () => { + const masked = service.mask('sk-1234567890abcdef'); + expect(masked).toBe('sk-1****cdef'); + }); + + it('masks short keys: first-2 + ****', () => { + const masked = service.mask('sk-abcd'); + expect(masked).toBe('sk****'); + }); + + it('handles exactly 8 chars as short path', () => { + const masked = service.mask('12345678'); + expect(masked).toBe('12****'); + }); + + it('handles 9 chars as long path', () => { + const masked = service.mask('123456789'); + expect(masked).toBe('1234****6789'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // redact + // ═══════════════════════════════════════════════════════════════════ + + describe('redact', () => { + it('replaces sk- prefixed tokens in log messages', () => { + const msg = 'Calling API with key sk-1234567890abc and secret sk-abcdefghijklmno'; + const redacted = service.redact(msg); + expect(redacted).not.toContain('sk-1234567890abc'); + expect(redacted).not.toContain('sk-abcdefghijklmno'); + expect(redacted).toContain('***REDACTED***'); + }); + + it('does not modify messages without API keys', () => { + const msg = 'User u1 created job j1 successfully'; + expect(service.redact(msg)).toBe(msg); + }); + + it('does not redact short sk- strings (< 10 chars after prefix)', () => { + const msg = 'sk-short is not a key'; + expect(service.redact(msg)).toBe(msg); + }); + + it('handles empty string', () => { + expect(service.redact('')).toBe(''); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // getEncryptionKey error + // ═══════════════════════════════════════════════════════════════════ + + describe('missing config', () => { + it('throws when CREDENTIAL_ENCRYPTION_KEY is not configured', () => { + mockConfig.get.mockReturnValue(null); + expect(() => service.encrypt('foo')).toThrow('CREDENTIAL_ENCRYPTION_KEY not configured'); + expect(() => service.decrypt('foo')).toThrow('CREDENTIAL_ENCRYPTION_KEY not configured'); + }); + }); +});