/** * Unit tests for error-type fidelity with the Python SDK. * * Python distinguishes malformed *data* (ValueError) from a failed integrity * check (SecurityError). The JS port previously collapsed both into * SecurityError, which hid real bugs behind a security-sounding message. * Malformed data now surfaces as a plain Error; only genuine crypto failures * stay opaque. */ import { SecureCompletionClient } from '../../src/core/SecureCompletionClient'; import { SecurityError, APIConnectionError } from '../../src/errors'; import { AESEncryption } from '../../src/core/crypto/encryption'; import { RSAOperations } from '../../src/core/crypto/rsa'; import { arrayBufferToBase64, stringToArrayBuffer, } from '../../src/core/crypto/utils'; const TAG_LENGTH = 16; /** Build a wire-format encrypted package addressed to `pubPem`. */ async function sealTo(pubPem: string, plaintext: Uint8Array): Promise { const aes = new AESEncryption(); const rsa = new RSAOperations(); const aesKey = await aes.generateKey(); const rawKey = await aes.exportKey(aesKey); const { ciphertext, nonce } = await aes.encrypt( plaintext.buffer.slice( plaintext.byteOffset, plaintext.byteOffset + plaintext.byteLength ) as ArrayBuffer, aesKey ); // Web Crypto appends the GCM tag; the wire format keeps them separate const combined = new Uint8Array(ciphertext); const ct = combined.slice(0, combined.length - TAG_LENGTH); const tag = combined.slice(combined.length - TAG_LENGTH); const serverKey = await rsa.importPublicKey(pubPem); const encryptedAesKey = await rsa.encryptKey(rawKey, serverKey); return stringToArrayBuffer(JSON.stringify({ version: '1.0', algorithm: 'hybrid-aes256-rsa4096', encrypted_payload: { ciphertext: arrayBufferToBase64(ct.buffer), nonce: arrayBufferToBase64(nonce), tag: arrayBufferToBase64(tag.buffer), }, encrypted_aes_key: arrayBufferToBase64(encryptedAesKey), key_algorithm: 'RSA-OAEP-SHA256', payload_algorithm: 'AES-256-GCM', })); } function newClient(): SecureCompletionClient { return new SecureCompletionClient({ routerUrl: 'https://router.test', keySize: 2048, keyRotationInterval: 0, }); } async function withKeys(client: SecureCompletionClient): Promise { await client.generateKeys(); return (client as unknown as { keyManager: { getPublicKeyPEM: () => Promise }; }).keyManager.getPublicKeyPEM(); } describe('decryptResponse error types', () => { test('authenticated-but-unparseable plaintext is a format error, not a security error', async () => { const client = newClient(); const pubPem = await withKeys(client); const sealed = await sealTo(pubPem, new TextEncoder().encode('not json at all')); const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e); expect(error).toBeInstanceOf(Error); expect(error).not.toBeInstanceOf(SecurityError); expect((error as Error).message).toBe('Decrypted response is not valid JSON'); client.dispose(); }); test('valid JSON of the wrong shape is reported as a schema failure', async () => { const client = newClient(); const pubPem = await withKeys(client); const sealed = await sealTo(pubPem, new TextEncoder().encode('{"not":"a completion"}')); await expect(client.decryptResponse(sealed, 'pid')) .rejects.toThrow('does not conform to expected schema'); client.dispose(); }); test('a tampered ciphertext stays opaque', async () => { const client = newClient(); const pubPem = await withKeys(client); const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}')); // Flip a byte of the ciphertext so GCM authentication fails const pkg = JSON.parse(new TextDecoder().decode(sealed)) as { encrypted_payload: { ciphertext: string }; }; const raw = Buffer.from(pkg.encrypted_payload.ciphertext, 'base64'); raw[0] ^= 0xff; pkg.encrypted_payload.ciphertext = raw.toString('base64'); const error = await client .decryptResponse(stringToArrayBuffer(JSON.stringify(pkg)), 'pid') .catch((e: unknown) => e); expect(error).toBeInstanceOf(SecurityError); // No detail about which stage failed expect((error as Error).message) .toBe('Decryption failed: integrity check or authentication failed'); client.dispose(); }); test('decrypting without keys reports the missing key, not an integrity failure', async () => { const client = newClient(); const other = newClient(); const pubPem = await withKeys(other); const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}')); const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e); expect(error).toBeInstanceOf(SecurityError); expect((error as Error).message).toMatch(/Private key not initialized/); client.dispose(); other.dispose(); }); test('a malformed package is rejected before any crypto runs', async () => { const client = newClient(); await withKeys(client); await expect( client.decryptResponse(stringToArrayBuffer('{"version":"1.0"}'), 'pid') ).rejects.toThrow('Missing required field'); client.dispose(); }); }); describe('fetchServerPublicKey error types', () => { function clientWithResponse(response: { statusCode: number; body: ArrayBuffer }) { const client = newClient(); (client as unknown as { httpClient: unknown }).httpClient = { get: jest.fn(async () => ({ ...response, headers: {} })), post: jest.fn(), }; return client; } test('a non-200 is a server-response problem, not a connection problem', async () => { const client = clientWithResponse({ statusCode: 500, body: stringToArrayBuffer('boom'), }); const error = await client.fetchServerPublicKey().catch((e: unknown) => e); expect(error).toBeInstanceOf(Error); expect(error).not.toBeInstanceOf(APIConnectionError); expect((error as Error).message).toMatch(/HTTP 500/); client.dispose(); }); test('an unparseable key is a format problem, not a connection problem', async () => { const client = clientWithResponse({ statusCode: 200, body: stringToArrayBuffer('-----BEGIN PUBLIC KEY-----\nnonsense\n-----END PUBLIC KEY-----'), }); const error = await client.fetchServerPublicKey().catch((e: unknown) => e); expect(error).toBeInstanceOf(Error); expect(error).not.toBeInstanceOf(APIConnectionError); expect((error as Error).message).toBe('Server returned invalid public key format'); client.dispose(); }); test('a transport failure is still a connection error', async () => { const client = newClient(); (client as unknown as { httpClient: unknown }).httpClient = { get: jest.fn(async () => { throw new Error('ECONNREFUSED'); }), post: jest.fn(), }; await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(APIConnectionError); client.dispose(); }); }); describe('defaults', () => { test('request timeout matches the Python SDK (900 s)', () => { const client = newClient(); expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(900000); client.dispose(); }); test('an explicit timeout still wins', () => { const client = new SecureCompletionClient({ routerUrl: 'https://router.test', keyRotationInterval: 0, timeout: 5000, }); expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(5000); client.dispose(); }); });