learning_ai_common_plat/packages/field-encrypt/src/index.test.ts
saravanakumardb1 7613d6890f feat(field-encrypt): admin-panel encryption toggle via feature flags
- FieldEncryptorConfig.enabled: false returns NullFieldEncryptor (no-op)
- NullFieldEncryptor stores plaintext as-is, decrypt returns ct directly
- 7 new tests for toggle behavior (50/50 total)
- encryption_enabled added to COMMON_FLAGS (seeded for all 10 products)
2026-03-21 15:24:19 -07:00

492 lines
17 KiB
TypeScript

/**
* @bytelyst/field-encrypt — Tests
*
* ~35 tests covering AES-GCM, key providers, envelope, cache,
* field encryptor factory, type guards, and migration.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { randomBytes } from 'node:crypto';
import {
createFieldEncryptor,
FieldEncryptor,
NullFieldEncryptor,
isEncryptedField,
encryptField,
decryptField,
generateAesKey,
buildDekId,
getOrCreateDek,
rewrapAllDeks,
DekCache,
MemoryDekStore,
MemoryKeyProvider,
EnvKeyProvider,
migrateDocuments,
} from './index.js';
import type { EncryptedField, FieldEncryptContext } from './types.js';
// ── AES-256-GCM ─────────────────────────────────────
describe('aes-gcm', () => {
const key = generateAesKey();
const dekId = 'dek_test_ctx';
it('encrypt → decrypt roundtrip', () => {
const plaintext = 'Hello, sensitive data!';
const encrypted = encryptField(plaintext, key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('encrypt → decrypt with AAD', () => {
const plaintext = 'With AAD';
const aad = 'user_123:transcripts';
const encrypted = encryptField(plaintext, key, dekId, aad);
const decrypted = decryptField(encrypted, key, aad);
expect(decrypted).toBe(plaintext);
});
it('rejects decryption with wrong AAD', () => {
const encrypted = encryptField('secret', key, dekId, 'correct_aad');
expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow();
});
it('rejects decryption with tampered ciphertext', () => {
const encrypted = encryptField('secret', key, dekId);
const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' };
expect(() => decryptField(tampered, key)).toThrow();
});
it('rejects decryption with tampered auth tag', () => {
const encrypted = encryptField('secret', key, dekId);
const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) };
expect(() => decryptField(tampered, key)).toThrow();
});
it('handles empty string', () => {
const encrypted = encryptField('', key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe('');
});
it('handles unicode content', () => {
const plaintext = '日本語テスト 🔐 Ñoño';
const encrypted = encryptField(plaintext, key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('handles large payload (100 KB)', () => {
const plaintext = 'x'.repeat(100_000);
const encrypted = encryptField(plaintext, key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('rejects wrong key size', () => {
const shortKey = randomBytes(16);
expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/);
});
it('produces correct EncryptedField shape', () => {
const encrypted = encryptField('test', key, dekId);
expect(encrypted.__encrypted).toBe(true);
expect(encrypted.v).toBe(1);
expect(encrypted.alg).toBe('aes-256-gcm');
expect(encrypted.dekId).toBe(dekId);
expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars
expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars
expect(encrypted.ct.length).toBeGreaterThan(0);
});
it('generates unique IVs per encryption', () => {
const e1 = encryptField('same', key, dekId);
const e2 = encryptField('same', key, dekId);
expect(e1.iv).not.toBe(e2.iv);
expect(e1.ct).not.toBe(e2.ct);
});
});
// ── Type guard ──────────────────────────────────────
describe('isEncryptedField', () => {
it('returns true for valid EncryptedField', () => {
const field: EncryptedField = {
__encrypted: true,
v: 1,
alg: 'aes-256-gcm',
ct: 'abc',
iv: '012',
tag: '345',
dekId: 'dek_1',
};
expect(isEncryptedField(field)).toBe(true);
});
it('returns false for string', () => {
expect(isEncryptedField('hello')).toBe(false);
});
it('returns false for null', () => {
expect(isEncryptedField(null)).toBe(false);
});
it('returns false for undefined', () => {
expect(isEncryptedField(undefined)).toBe(false);
});
it('returns false for object without __encrypted', () => {
expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false);
});
it('returns false for object with __encrypted: false', () => {
expect(
isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' })
).toBe(false);
});
});
// ── Key providers ───────────────────────────────────
describe('MemoryKeyProvider', () => {
it('wrap → unwrap roundtrip', async () => {
const provider = new MemoryKeyProvider();
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
expect(unwrapped).toEqual(dek);
});
});
describe('EnvKeyProvider', () => {
it('wrap → unwrap roundtrip with 64-char hex key', async () => {
const hexKey = randomBytes(32).toString('hex');
const provider = new EnvKeyProvider(hexKey);
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
expect(unwrapped).toEqual(dek);
});
it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => {
const provider = new EnvKeyProvider('my-dev-secret-key');
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
expect(unwrapped).toEqual(dek);
});
it('throws on empty key', () => {
expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/);
});
});
// ── DEK cache ───────────────────────────────────────
describe('DekCache', () => {
let cache: DekCache;
beforeEach(() => {
cache = new DekCache(1000, 3); // 1s TTL, max 3 entries
});
it('get returns null on miss', () => {
expect(cache.get('nonexistent')).toBeNull();
});
it('set + get roundtrip', () => {
const key = generateAesKey();
cache.set('dek_1', key);
expect(cache.get('dek_1')).toEqual(key);
});
it('expires entries after TTL', async () => {
const shortCache = new DekCache(50, 100); // 50ms TTL
const key = generateAesKey();
shortCache.set('dek_1', key);
expect(shortCache.get('dek_1')).toEqual(key);
await new Promise(r => setTimeout(r, 60));
expect(shortCache.get('dek_1')).toBeNull();
});
it('evicts oldest on max size', () => {
cache.set('a', generateAesKey());
cache.set('b', generateAesKey());
cache.set('c', generateAesKey());
// At max size (3), adding 'd' should evict 'a'
cache.set('d', generateAesKey());
expect(cache.get('a')).toBeNull();
expect(cache.get('d')).not.toBeNull();
});
it('invalidate removes specific entry', () => {
cache.set('dek_1', generateAesKey());
cache.invalidate('dek_1');
expect(cache.get('dek_1')).toBeNull();
});
it('tracks hit rate', () => {
cache.set('dek_1', generateAesKey());
cache.recordHit();
cache.recordHit();
cache.recordMiss();
expect(cache.hitRate).toBe(67); // 2/3 = 67%
});
});
// ── Envelope ────────────────────────────────────────
describe('envelope', () => {
it('buildDekId produces correct format', () => {
expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts');
});
it('getOrCreateDek creates and caches a new DEK', async () => {
const provider = new MemoryKeyProvider();
const store = new MemoryDekStore();
const cache = new DekCache();
const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache);
expect(dek).toHaveLength(32);
// Should be in store
const stored = await store.get('dek_u1_ctx');
expect(stored).not.toBeNull();
// Should be cached — second call should return same key
const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache);
expect(dek2).toEqual(dek);
});
it('rewrapAllDeks re-wraps with new provider', async () => {
const oldProvider = new MemoryKeyProvider(undefined, 'old-v1');
const newProvider = new MemoryKeyProvider(undefined, 'new-v1');
const store = new MemoryDekStore();
const cache = new DekCache();
// Create 3 DEKs with old provider
await getOrCreateDek('dek_1', oldProvider, store, cache);
await getOrCreateDek('dek_2', oldProvider, store, cache);
await getOrCreateDek('dek_3', oldProvider, store, cache);
// Re-wrap
const count = await rewrapAllDeks(oldProvider, newProvider, store, cache);
expect(count).toBe(3);
// Verify new provider can unwrap
const stored = await store.get('dek_1');
expect(stored).not.toBeNull();
expect(stored!.mekVersion).toBe('new-v1');
const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion);
expect(unwrapped).toHaveLength(32);
});
});
// ── FieldEncryptor (integration) ────────────────────
describe('FieldEncryptor', () => {
let encryptor: FieldEncryptor;
const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' };
beforeEach(() => {
encryptor = createFieldEncryptor({ keyProvider: 'memory' });
});
it('encrypt → decrypt roundtrip', async () => {
const encrypted = await encryptor.encrypt('Hello World', ctx);
expect(encrypted.__encrypted).toBe(true);
const decrypted = await encryptor.decrypt(encrypted, ctx);
expect(decrypted).toBe('Hello World');
});
it('encryptBatch → decryptBatch roundtrip', async () => {
const plaintexts = ['one', 'two', 'three'];
const encrypted = await encryptor.encryptBatch(plaintexts, ctx);
expect(encrypted).toHaveLength(3);
const decrypted = await encryptor.decryptBatch(encrypted, ctx);
expect(decrypted).toEqual(plaintexts);
});
it('isEncrypted works via encryptor', async () => {
const encrypted = await encryptor.encrypt('test', ctx);
expect(encryptor.isEncrypted(encrypted)).toBe(true);
expect(encryptor.isEncrypted('plaintext')).toBe(false);
});
it('different users get different DEKs', async () => {
const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' };
const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' };
const e1 = await encryptor.encrypt('same text', ctx1);
const e2 = await encryptor.encrypt('same text', ctx2);
expect(e1.dekId).not.toBe(e2.dekId);
expect(e1.ct).not.toBe(e2.ct);
});
it('JSON-serialized array encryption roundtrip', async () => {
const transcript = [
{ role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' },
{ role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' },
];
const serialized = JSON.stringify(transcript);
const encrypted = await encryptor.encrypt(serialized, ctx);
const decrypted = await encryptor.decrypt(encrypted, ctx);
expect(JSON.parse(decrypted)).toEqual(transcript);
});
});
// ── Factory config validation ───────────────────────
describe('createFieldEncryptor config', () => {
it('throws on unknown provider', () => {
expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/);
});
it('throws on env provider without key', () => {
expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/);
});
it('throws on akv provider without vaultUrl', () => {
expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow(
/keyVaultUrl/
);
});
it('throws on akv provider without mekName', () => {
expect(() =>
createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' })
).toThrow(/mekName/);
});
it('env provider works with hex key', async () => {
const hexKey = randomBytes(32).toString('hex');
const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey });
const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' };
const encrypted = await enc.encrypt('secret', ctx);
const decrypted = await enc.decrypt(encrypted, ctx);
expect(decrypted).toBe('secret');
});
});
// ── Encryption toggle (enabled/disabled) ────────────
describe('enabled: false (NullFieldEncryptor)', () => {
const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' };
it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
expect(enc).toBeInstanceOf(NullFieldEncryptor);
});
it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => {
const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' });
expect(enc).not.toBeInstanceOf(NullFieldEncryptor);
expect(enc).toBeInstanceOf(FieldEncryptor);
});
it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => {
const enc = createFieldEncryptor({ keyProvider: 'memory' });
expect(enc).not.toBeInstanceOf(NullFieldEncryptor);
});
it('encrypt returns sentinel with plaintext in ct field', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const result = await enc.encrypt('hello world', ctx);
expect(result.__encrypted).toBe(true);
expect(result.ct).toBe('hello world');
expect(result.iv).toBe('disabled');
expect(result.tag).toBe('disabled');
expect(result.dekId).toBe('disabled');
});
it('decrypt returns plaintext from ct field', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const encrypted = await enc.encrypt('secret', ctx);
const decrypted = await enc.decrypt(encrypted, ctx);
expect(decrypted).toBe('secret');
});
it('encryptBatch returns sentinels for all items', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const results = await enc.encryptBatch(['a', 'b', 'c'], ctx);
expect(results).toHaveLength(3);
expect(results[0].ct).toBe('a');
expect(results[1].ct).toBe('b');
expect(results[2].ct).toBe('c');
});
it('decryptBatch returns plaintexts from ct fields', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const encrypted = await enc.encryptBatch(['x', 'y'], ctx);
const decrypted = await enc.decryptBatch(encrypted, ctx);
expect(decrypted).toEqual(['x', 'y']);
});
it('isEncrypted returns true for disabled sentinel', async () => {
const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
const result = await enc.encrypt('test', ctx);
expect(enc.isEncrypted(result)).toBe(true);
});
});
// ── Migration ───────────────────────────────────────
describe('migrateDocuments', () => {
it('encrypts plaintext fields and skips already-encrypted', async () => {
const encryptor = createFieldEncryptor({ keyProvider: 'memory' });
const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' };
const alreadyEncrypted = await encryptor.encrypt('old', ctx);
const docs = [
{ id: '1', body: 'plaintext note' },
{ id: '2', body: alreadyEncrypted },
{ id: '3', body: 'another note' },
{ id: '4', body: null },
];
const written: Array<{ id: string; body: EncryptedField }> = [];
const result = await migrateDocuments({
fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize),
getId: doc => doc.id,
getField: doc => doc.body,
encryptValue: plaintext => encryptor.encrypt(plaintext, ctx),
writeBack: async (doc, encrypted) => {
written.push({ id: doc.id, body: encrypted });
},
batchSize: 10,
});
expect(result.scanned).toBe(4);
expect(result.encrypted).toBe(2); // id 1 + id 3
expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null)
expect(result.errors).toBe(0);
expect(written).toHaveLength(2);
});
it('dry run does not write', async () => {
const encryptor = createFieldEncryptor({ keyProvider: 'memory' });
const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' };
const docs = [{ id: '1', body: 'plaintext' }];
let writeCount = 0;
const result = await migrateDocuments({
fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize),
getId: doc => doc.id,
getField: doc => doc.body,
encryptValue: plaintext => encryptor.encrypt(plaintext, ctx),
writeBack: async () => {
writeCount++;
},
dryRun: true,
});
expect(result.encrypted).toBe(1);
expect(writeCount).toBe(0);
});
});