Files
gitmost/apps/server/src/integrations/crypto/secret-box.spec.ts
claude code agent 227 f1980cf425 test(ai-chat): safety-critical coverage + a11y + pure refactors
Unit tests for the safety-critical paths: crypto secret-box (round-trip,
tamper detection, wrong key), the SSRF guard (blocked ranges + DNS-rebinding),
the ai-chat tools service, the page-embedding repo, and the
assistant-parts/serialization helpers. Those server helpers (assistantParts,
rowToUiMessage, serializeSteps) are exported ONLY for the tests — no runtime
change.

Also: keyboard a11y on the chat history header and conversation rows
(role/tabIndex/Enter+Space), and DRY refactors that move shared logic into one
place (isToolPart -> tool-parts util; buildInitialValues in the MCP form).

The behaviour-changing edits that previously rode along in this commit are
split out into the following two commits, per review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:58:44 +03:00

78 lines
3.2 KiB
TypeScript

import { SecretBoxService } from './secret-box';
import { EnvironmentService } from '../environment/environment.service';
/**
* Unit tests for SecretBoxService: the AES-256-GCM helper that protects provider
* API keys at rest. The contract is: encrypt -> decrypt round-trips the input;
* two encryptions of the same input yield different blobs (random salt+iv) yet
* both decrypt; a tampered blob or a different APP_SECRET fails decryption with
* the recoverable "APP_SECRET may have changed" message the UI relies on.
*/
describe('SecretBoxService', () => {
// Construct a SecretBoxService whose EnvironmentService.getAppSecret returns a
// fixed 64-hex secret. Only getAppSecret is exercised, so a thin fake suffices.
function makeBox(appSecret: string): SecretBoxService {
const env = {
getAppSecret: () => appSecret,
} as unknown as EnvironmentService;
return new SecretBoxService(env);
}
const SECRET_A =
'00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
const SECRET_B =
'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
it('round-trips: decrypt(encrypt(x)) === x', () => {
const box = makeBox(SECRET_A);
const plain = 'sk-super-secret-provider-key-12345';
const blob = box.encryptSecret(plain);
expect(box.decryptSecret(blob)).toBe(plain);
});
it('produces a different blob each time, both of which decrypt', () => {
const box = makeBox(SECRET_A);
const plain = 'identical-input';
const blob1 = box.encryptSecret(plain);
const blob2 = box.encryptSecret(plain);
// Random per-record salt + iv => the ciphertext blobs must differ.
expect(blob1).not.toBe(blob2);
expect(box.decryptSecret(blob1)).toBe(plain);
expect(box.decryptSecret(blob2)).toBe(plain);
});
it('throws the recoverable error on a tampered auth tag', () => {
const box = makeBox(SECRET_A);
const blob = box.encryptSecret('tamper-me');
// Layout: base64( salt[16] | iv[12] | authTag[16] | ciphertext ). Flip a bit
// in the auth-tag region so GCM verification (decipher.final) rejects it.
const data = Buffer.from(blob, 'base64');
const authTagByteIndex = 16 + 12; // first byte of the auth tag
data[authTagByteIndex] = data[authTagByteIndex] ^ 0xff;
const tampered = data.toString('base64');
expect(() => box.decryptSecret(tampered)).toThrow(/APP_SECRET may have changed/);
});
it('throws the recoverable error on a tampered ciphertext byte', () => {
const box = makeBox(SECRET_A);
const blob = box.encryptSecret('tamper-the-body');
const data = Buffer.from(blob, 'base64');
// Last byte is part of the ciphertext; flipping it must fail GCM auth.
data[data.length - 1] = data[data.length - 1] ^ 0xff;
const tampered = data.toString('base64');
expect(() => box.decryptSecret(tampered)).toThrow(/APP_SECRET may have changed/);
});
it('throws when decrypting under a different APP_SECRET', () => {
const boxA = makeBox(SECRET_A);
const boxB = makeBox(SECRET_B);
const blob = boxA.encryptSecret('rotate-me');
// A different APP_SECRET derives a different scrypt key => GCM auth fails.
expect(() => boxB.decryptSecret(blob)).toThrow(/APP_SECRET may have changed/);
});
});