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>
This commit is contained in:
committed by
vvzvlad
parent
c8af637654
commit
f1980cf425
77
apps/server/src/integrations/crypto/secret-box.spec.ts
Normal file
77
apps/server/src/integrations/crypto/secret-box.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user