feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset)
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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<typeof makeMockClient>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(params.p_recovery_sealed_b64).toBe('BA==');
|
||||
expect(params.p_recovery_salt_b64).toBe('BQ==');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user