test: E2E ZK integration tests + frontend lifecycle test

Backend ZK integration tests (tests/zk_integration_tests.rs):
- medication_stored_as_ciphertext: register → create med with opaque blob →
  response echoes blob, no plaintext fields leaked → GET echoes blob.
- appointment_stored_as_ciphertext_with_top_level_status: opaque blob + status
  filter works server-side without decryption.
- health_stat_stored_as_ciphertext: opaque blob, no value/stat_type leaked.
- dose_schedule_adherence_reflects_missed_doses: 1×/day schedule → 30 scheduled,
  1 taken, 29 missed, ~3.3% rate.

Updated medication_tests.rs for the opaque-blob contract (was sending old
plaintext name/dosage fields).

Frontend lifecycle test (useStore.test.ts):
- Full ZK round-trip with real WebCrypto: setup → encrypt → verify ciphertext →
  unlock with password → decrypt → recover via phrase → rewrap under new
  password → decrypt with new key. Data survives the full lifecycle.

Verified: backend 24 tests 0 warnings; frontend 25 tests, build clean.
This commit is contained in:
goose 2026-07-05 00:35:33 -03:00
parent 288e776a8c
commit 34e3b0b0e0
3 changed files with 351 additions and 12 deletions

View file

@ -1,6 +1,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto';
import { encryptJson } from '../crypto';
import {
deriveAuthAndEncKeys,
setEncKey,
clearEncKey,
encryptJson,
setupEncryption,
unlockWithPassword,
unlockWithRecovery,
rewrapDek,
} from '../crypto';
// Mock the api client so the store's real reducer logic is exercised without HTTP.
const apiMock = {
@ -117,3 +125,57 @@ describe('useMedicationStore', () => {
expect(useMedicationStore.getState().adherence['m1'].adherence_rate).toBe(100);
});
});
describe('zero-knowledge medication lifecycle (register → create → recover)', () => {
// This test exercises the full ZK flow using real WebCrypto + the real store,
// with mocked HTTP. It proves: encrypt-on-write, decrypt-on-read, and that
// data encrypted under one key can be recovered via the recovery phrase and
// decrypted under a re-wrapped key.
it('full lifecycle: data survives password recovery', async () => {
const password = 'lifecycle-password';
const recoveryPhrase = 'lifecycle-recovery';
const medData = { name: 'Lisinopril', dosage: '10mg', frequency: 'daily' };
// 1. Setup encryption (register): generate DEK, wrap under password + recovery.
const setup = await setupEncryption(password, recoveryPhrase);
setEncKey(setup.dek);
// 2. Encrypt the medication data (what the store does on create).
const encrypted = await encryptJson(medData, setup.dek);
expect(encrypted.data).not.toContain('Lisinopril'); // ciphertext, not plaintext
// 3. Simulate page reload: DEK is lost.
clearEncKey();
// 4. Unlock with password (the unlock screen).
const unlockResult = await unlockWithPassword(password, setup.passwordWrappedDek);
setEncKey(unlockResult.dek);
// 5. Decrypt the data with the unlocked DEK.
const decrypted1 = await encryptJson(medData, unlockResult.dek).then((enc) =>
// We can't decrypt what we just encrypted in the same call; use the
// original encrypted blob instead.
Promise.resolve(enc),
);
// Use the ORIGINAL encrypted blob (from step 2) to verify round-trip.
const { decryptJson } = await import('../crypto');
const roundTripped = await decryptJson<typeof medData>(encrypted, unlockResult.dek);
expect(roundTripped.name).toBe('Lisinopril');
// 6. Simulate forgot-password: recover via recovery phrase.
clearEncKey();
const recoveredDek = await unlockWithRecovery(recoveryPhrase, setup.recoveryWrappedDek!);
const recoveredData = await decryptJson<typeof medData>(encrypted, recoveredDek);
expect(recoveredData.name).toBe('Lisinopril');
expect(recoveredData.dosage).toBe('10mg');
// 7. Re-wrap under a new password (post-recovery).
const newPassword = 'new-lifecycle-password';
const rewrapped = await rewrapDek(recoveredDek, newPassword);
const newUnlock = await unlockWithPassword(newPassword, rewrapped);
const finalData = await decryptJson<typeof medData>(encrypted, newUnlock.dek);
expect(finalData.name).toBe('Lisinopril');
clearEncKey();
});
});