import { describe, expect, it, beforeEach } from 'vitest'; import { makeMockClient } from './__tests__/mockClient'; import { fetchUserKeyBlob, uploadUserKeyBlob, tryUnlockUserKey, recordPinAttempt, resetUserKey, } from './userKey'; const USER_ID = '22222222-2222-2222-2222-222222222222'; describe('auth/userKey', () => { let mock: ReturnType; beforeEach(() => { mock = makeMockClient(USER_ID); }); it('tryUnlockUserKey reports exists=false when row missing', async () => { mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null }); const res = await tryUnlockUserKey(mock.client, USER_ID); expect(res.exists).toBe(false); expect(mock.rpcCalls).toEqual([{ name: 'try_unlock_user_key', params: { p_user_id: USER_ID } }]); }); it('tryUnlockUserKey returns ciphertext + salt when unlocked', async () => { mock.setRpcResponse('try_unlock_user_key', { data: { exists: true, locked: false, sealed_private_key: 'AAA=', salt: 'BBB=', kdf_params: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 }, recovery_sealed_private_key: null, recovery_salt: null, failed_attempts: 0, failed_recovery_attempts: 0, recovery_locked_until: null, key_version: 1, }, error: null, }); const res = await tryUnlockUserKey(mock.client, USER_ID); expect(res.exists).toBe(true); if (!res.exists) throw new Error(); expect(res.locked).toBe(false); if (res.locked) throw new Error(); expect(res.sealedPrivateKey).toBeInstanceOf(Uint8Array); expect(res.salt).toBeInstanceOf(Uint8Array); expect(res.kdfParams.preset).toBe('moderate'); }); it('tryUnlockUserKey returns lockout state without ciphertext', async () => { const lockedUntil = '2026-05-16T00:00:00Z'; mock.setRpcResponse('try_unlock_user_key', { data: { exists: true, locked: true, locked_until: lockedUntil }, error: null, }); const res = await tryUnlockUserKey(mock.client, USER_ID); expect(res.exists).toBe(true); if (!res.exists) throw new Error(); expect(res.locked).toBe(true); if (!res.locked) throw new Error(); expect(res.lockedUntil).toBe(lockedUntil); }); it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => { mock.setRpcResponse('reset_user_key', { data: 0, error: null }); await uploadUserKeyBlob(mock.client, { userId: USER_ID, publicKey: new Uint8Array([1, 2, 3]), sealedPrivateKey: new Uint8Array([4, 5]), salt: new Uint8Array([6]), kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 }, }); const params = mock.rpcCalls.at(-1)?.params as Record; expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key'); expect(params.p_user_id).toBe(USER_ID); expect(params.p_public_key_b64).toBe('AQID'); expect(params.p_sealed_private_b64).toBe('BAU='); expect(params.p_salt_b64).toBe('Bg=='); expect(params.p_recovery_sealed_b64).toBeNull(); }); it('recordPinAttempt forwards success/recovery flags', async () => { mock.setRpcResponse('record_pin_attempt', { data: { failed_attempts: 0 }, error: null }); await recordPinAttempt(mock.client, USER_ID, false, false); expect(mock.rpcCalls.at(-1)?.params).toEqual({ p_user_id: USER_ID, p_success: false, p_recovery: false, }); }); it('fetchUserKeyBlob returns null when exists=false', async () => { mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null }); const res = await fetchUserKeyBlob(mock.client, USER_ID); expect(res).toBeNull(); }); it('resetUserKey forwards recovery params', async () => { mock.setRpcResponse('reset_user_key', { data: 5, error: null }); const deleted = await resetUserKey(mock.client, { userId: USER_ID, publicKey: new Uint8Array([1]), sealedPrivateKey: new Uint8Array([2]), salt: new Uint8Array([3]), kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 1 }, recoverySealedPrivateKey: new Uint8Array([4]), recoverySalt: new Uint8Array([5]), }); expect(deleted).toBe(5); const params = mock.rpcCalls.at(-1)?.params as Record; expect(params.p_recovery_sealed_b64).toBe('BA=='); expect(params.p_recovery_salt_b64).toBe('BQ=='); }); });