diff --git a/apps/api/tests/broker/crypto.test.ts b/apps/api/tests/broker/crypto.test.ts index e79a8d0..c31932c 100644 --- a/apps/api/tests/broker/crypto.test.ts +++ b/apps/api/tests/broker/crypto.test.ts @@ -1,22 +1,80 @@ /** - * Wave 0 stub — Broker: AES-GCM app-password encryption + * Broker: AES-GCM app-password encryption * - * These tests are RED stubs. Implementation lives in: - * apps/api/src/broker/crypto.ts (Plan 03) - * - * Tests will be filled GREEN in Plan 03 when crypto helpers are implemented. + * Tests the encryptPassword / decryptPassword helpers in src/broker/crypto.ts. + * Key behaviors verified (T-03-01, T-03-03): + * - Lossless roundtrip + * - Unique IVs per encryption (never reuse IV) + * - GCM auth tag tamper detection + * - Stored payload shape */ -import { describe, it } from 'vitest' +import { describe, it, expect, beforeAll } from 'vitest' + +// Set a fixed 32-byte (64-char hex) key before importing the module +const TEST_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2' + +beforeAll(() => { + process.env.APP_PASSWORD_ENCRYPTION_KEY = TEST_KEY +}) + +// Dynamic import so env is set before module-level KEY evaluation +async function getCrypto() { + return import('../../src/broker/crypto.js') +} describe('encryptPassword / decryptPassword', () => { - it.todo('roundtrip: decrypt(encrypt(plaintext)) === plaintext (Plan 03)') + it('roundtrip: decrypt(encrypt(plaintext)) === plaintext', async () => { + const { encryptPassword, decryptPassword } = await getCrypto() + const plaintext = 'my-fastmail-app-password-abc123' + const encrypted = encryptPassword(plaintext) + expect(decryptPassword(encrypted)).toBe(plaintext) + }) - it.todo('different IVs produce different ciphertext for the same plaintext (Plan 03)') + it('different IVs produce different ciphertext for the same plaintext', async () => { + const { encryptPassword } = await getCrypto() + const plaintext = 'same-password' + const enc1 = encryptPassword(plaintext) + const enc2 = encryptPassword(plaintext) + // The JSON payloads must differ (different IVs → different ciphertext) + expect(enc1).not.toBe(enc2) + // And the IVs themselves must differ + const p1 = JSON.parse(enc1) + const p2 = JSON.parse(enc2) + expect(p1.iv).not.toBe(p2.iv) + }) - it.todo('decrypting with a tampered authTag throws (Plan 03)') + it('decrypting with a tampered authTag throws', async () => { + const { encryptPassword, decryptPassword } = await getCrypto() + const encrypted = encryptPassword('secret') + const payload = JSON.parse(encrypted) + // Flip first byte of authTag + payload.authTag = ('ff' + payload.authTag.slice(2)) + expect(() => decryptPassword(JSON.stringify(payload))).toThrow() + }) - it.todo('decrypting with a tampered ciphertext throws (Plan 03)') + it('decrypting with a tampered ciphertext throws', async () => { + const { encryptPassword, decryptPassword } = await getCrypto() + const encrypted = encryptPassword('secret') + const payload = JSON.parse(encrypted) + // Flip first byte of ciphertext + payload.ciphertext = ('ff' + payload.ciphertext.slice(2)) + expect(() => decryptPassword(JSON.stringify(payload))).toThrow() + }) - it.todo('stored payload is valid JSON with iv, authTag, ciphertext fields (Plan 03)') + it('stored payload is valid JSON with iv, authTag, ciphertext fields', async () => { + const { encryptPassword } = await getCrypto() + const encrypted = encryptPassword('test-password') + const payload = JSON.parse(encrypted) + expect(payload).toHaveProperty('iv') + expect(payload).toHaveProperty('authTag') + expect(payload).toHaveProperty('ciphertext') + // All values are non-empty hex strings + expect(typeof payload.iv).toBe('string') + expect(payload.iv.length).toBeGreaterThan(0) + expect(typeof payload.authTag).toBe('string') + expect(payload.authTag.length).toBeGreaterThan(0) + expect(typeof payload.ciphertext).toBe('string') + expect(payload.ciphertext.length).toBeGreaterThan(0) + }) })