testing
This commit is contained in:
308
src/__tests__/api.test.ts
Normal file
308
src/__tests__/api.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Tests for the API service layer (src/lib/api.ts)
|
||||
*
|
||||
* All HTTP calls are intercepted by mocking global.fetch.
|
||||
* Tests verify correct URL construction, headers, methods, and error handling.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
registerUser,
|
||||
getUserByEmail,
|
||||
updateUserProfile,
|
||||
deleteUser,
|
||||
createEntry,
|
||||
getUserEntries,
|
||||
getEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
convertUTCToIST,
|
||||
} from '../lib/api'
|
||||
|
||||
const TOKEN = 'firebase-id-token'
|
||||
const USER_ID = '507f1f77bcf86cd799439011'
|
||||
const ENTRY_ID = '507f1f77bcf86cd799439022'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockFetch(body: unknown, status = 200) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockFetchError(detail: string, status: number) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status,
|
||||
statusText: 'Error',
|
||||
json: () => Promise.resolve({ detail }),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockFetchNetworkError() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('registerUser', () => {
|
||||
it('sends POST to /users/register', async () => {
|
||||
mockFetch({ id: USER_ID, email: 'a@b.com', message: 'User registered successfully' })
|
||||
await registerUser({ email: 'a@b.com' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/users/register'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('includes Authorization Bearer token in headers', async () => {
|
||||
mockFetch({})
|
||||
await registerUser({ email: 'a@b.com' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sends displayName and photoURL in body', async () => {
|
||||
mockFetch({})
|
||||
await registerUser({ email: 'a@b.com', displayName: 'Alice', photoURL: 'https://pic.url' }, TOKEN)
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body)
|
||||
expect(body).toMatchObject({ email: 'a@b.com', displayName: 'Alice' })
|
||||
})
|
||||
|
||||
it('returns the parsed response', async () => {
|
||||
const response = { id: USER_ID, email: 'a@b.com', message: 'User registered successfully' }
|
||||
mockFetch(response)
|
||||
const result = await registerUser({ email: 'a@b.com' }, TOKEN)
|
||||
expect(result).toEqual(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserByEmail', () => {
|
||||
it('sends GET to /users/by-email/{email}', async () => {
|
||||
mockFetch({ id: USER_ID, email: 'test@example.com' })
|
||||
await getUserByEmail('test@example.com', TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/users/by-email/test@example.com'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('throws "User not found" on 404', async () => {
|
||||
mockFetchError('User not found', 404)
|
||||
await expect(getUserByEmail('ghost@example.com', TOKEN)).rejects.toThrow('User not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserProfile', () => {
|
||||
it('sends PUT to /users/{userId}', async () => {
|
||||
mockFetch({ id: USER_ID, theme: 'dark', message: 'User updated successfully' })
|
||||
await updateUserProfile(USER_ID, { theme: 'dark' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/users/${USER_ID}`),
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
)
|
||||
})
|
||||
|
||||
it('sends only the provided fields', async () => {
|
||||
mockFetch({})
|
||||
await updateUserProfile(USER_ID, { displayName: 'New Name' }, TOKEN)
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body)
|
||||
expect(body).toMatchObject({ displayName: 'New Name' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('sends DELETE to /users/{userId}', async () => {
|
||||
mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 3 })
|
||||
await deleteUser(USER_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/users/${USER_ID}`),
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns deletion counts', async () => {
|
||||
mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 5 })
|
||||
const result = await deleteUser(USER_ID, TOKEN)
|
||||
expect(result).toMatchObject({ user_deleted: 1, entries_deleted: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createEntry', () => {
|
||||
const encryptedEntry = {
|
||||
encryption: {
|
||||
encrypted: true,
|
||||
ciphertext: 'dGVzdA==',
|
||||
nonce: 'bm9uY2U=',
|
||||
algorithm: 'XSalsa20-Poly1305',
|
||||
},
|
||||
}
|
||||
|
||||
it('sends POST to /entries/{userId}', async () => {
|
||||
mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' })
|
||||
await createEntry(USER_ID, encryptedEntry, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}`),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns entry id and message', async () => {
|
||||
mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' })
|
||||
const result = await createEntry(USER_ID, encryptedEntry, TOKEN)
|
||||
expect(result).toMatchObject({ id: ENTRY_ID })
|
||||
})
|
||||
|
||||
it('throws on 404 when user not found', async () => {
|
||||
mockFetchError('User not found', 404)
|
||||
await expect(createEntry('nonexistent-user', encryptedEntry, TOKEN)).rejects.toThrow('User not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserEntries', () => {
|
||||
it('sends GET to /entries/{userId} with default pagination', async () => {
|
||||
mockFetch({ entries: [], total: 0 })
|
||||
await getUserEntries(USER_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}?limit=50&skip=0`),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('respects custom limit and skip', async () => {
|
||||
mockFetch({ entries: [], total: 0 })
|
||||
await getUserEntries(USER_ID, TOKEN, 10, 20)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('limit=10&skip=20'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns entries and total', async () => {
|
||||
mockFetch({ entries: [{ id: ENTRY_ID }], total: 1 })
|
||||
const result = await getUserEntries(USER_ID, TOKEN)
|
||||
expect(result).toMatchObject({ total: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEntry', () => {
|
||||
it('sends GET to /entries/{userId}/{entryId}', async () => {
|
||||
mockFetch({ id: ENTRY_ID, userId: USER_ID, createdAt: '2024-01-01', updatedAt: '2024-01-01' })
|
||||
await getEntry(USER_ID, ENTRY_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('throws "Entry not found" on 404', async () => {
|
||||
mockFetchError('Entry not found', 404)
|
||||
await expect(getEntry(USER_ID, 'bad-id', TOKEN)).rejects.toThrow('Entry not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateEntry', () => {
|
||||
it('sends PUT to /entries/{userId}/{entryId}', async () => {
|
||||
mockFetch({ id: ENTRY_ID })
|
||||
await updateEntry(USER_ID, ENTRY_ID, { mood: 'happy' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteEntry', () => {
|
||||
it('sends DELETE to /entries/{userId}/{entryId}', async () => {
|
||||
mockFetch({ message: 'Entry deleted successfully' })
|
||||
await deleteEntry(USER_ID, ENTRY_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertUTCToIST', () => {
|
||||
it('sends POST to /entries/convert-timestamp/utc-to-ist', async () => {
|
||||
const utc = '2024-01-01T00:00:00Z'
|
||||
mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' })
|
||||
await convertUTCToIST(utc)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/convert-timestamp/utc-to-ist'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns both utc and ist fields', async () => {
|
||||
const utc = '2024-01-01T00:00:00Z'
|
||||
mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' })
|
||||
const result = await convertUTCToIST(utc)
|
||||
expect(result).toMatchObject({ utc, ist: expect.stringContaining('+05:30') })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic Error Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('API error handling', () => {
|
||||
it('throws the error detail from response body', async () => {
|
||||
mockFetchError('Specific backend error message', 400)
|
||||
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Specific backend error message')
|
||||
})
|
||||
|
||||
it('falls back to "API error: {statusText}" when body has no detail', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('no JSON')),
|
||||
}))
|
||||
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('API error: Internal Server Error')
|
||||
})
|
||||
|
||||
it('propagates network errors', async () => {
|
||||
mockFetchNetworkError()
|
||||
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('includes credentials: include in all requests', async () => {
|
||||
mockFetch({})
|
||||
await getUserByEmail('x@x.com', TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ credentials: 'include' })
|
||||
)
|
||||
})
|
||||
|
||||
it('sets Content-Type: application/json on all requests', async () => {
|
||||
mockFetch({})
|
||||
await getUserByEmail('x@x.com', TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
284
src/__tests__/crypto.test.ts
Normal file
284
src/__tests__/crypto.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Tests for client-side encryption utilities (src/lib/crypto.ts)
|
||||
*
|
||||
* Uses a self-consistent XOR-based sodium mock so tests run without
|
||||
* WebAssembly (libsodium) in the Node/happy-dom environment.
|
||||
* The real PBKDF2 key derivation (Web Crypto API) is tested as-is.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
deriveSecretKey,
|
||||
generateDeviceKey,
|
||||
encryptEntry,
|
||||
decryptEntry,
|
||||
encryptSecretKey,
|
||||
decryptSecretKey,
|
||||
generateSalt,
|
||||
getSalt,
|
||||
saveSalt,
|
||||
saveDeviceKey,
|
||||
getDeviceKey,
|
||||
clearDeviceKey,
|
||||
} from '../lib/crypto'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-consistent sodium mock (XOR cipher + 16-byte auth tag)
|
||||
// encrypt(msg, key) = tag(16 zeros) || xor(msg, key)
|
||||
// decrypt(ct, key) = xor(ct[16:], key)
|
||||
// Wrong-key behavior is tested by overriding crypto_secretbox_open_easy to throw.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||
return data.map((byte, i) => byte ^ key[i % key.length])
|
||||
}
|
||||
|
||||
const createMockSodium = (overrides: Record<string, unknown> = {}) => ({
|
||||
randombytes_buf: (size: number) => new Uint8Array(size).fill(42),
|
||||
crypto_secretbox_NONCEBYTES: 24,
|
||||
crypto_secretbox_easy: (msg: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
|
||||
const tag = new Uint8Array(16)
|
||||
const encrypted = xorBytes(msg, key)
|
||||
const result = new Uint8Array(tag.length + encrypted.length)
|
||||
result.set(tag)
|
||||
result.set(encrypted, tag.length)
|
||||
return result
|
||||
},
|
||||
crypto_secretbox_open_easy: (ct: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
|
||||
if (ct.length < 16) throw new Error('invalid ciphertext length')
|
||||
return xorBytes(ct.slice(16), key)
|
||||
},
|
||||
to_base64: (data: Uint8Array) => Buffer.from(data).toString('base64'),
|
||||
from_base64: (str: string) => new Uint8Array(Buffer.from(str, 'base64')),
|
||||
from_string: (str: string) => new TextEncoder().encode(str),
|
||||
to_string: (data: Uint8Array) => new TextDecoder().decode(data),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
vi.mock('../utils/sodium', () => ({
|
||||
getSodium: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('crypto utilities', () => {
|
||||
beforeEach(async () => {
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium).mockResolvedValue(createMockSodium() as never)
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// ── deriveSecretKey ──────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveSecretKey', () => {
|
||||
it('returns a 32-byte Uint8Array', async () => {
|
||||
const key = await deriveSecretKey('test-uid-123', 'test-salt')
|
||||
expect(key).toBeInstanceOf(Uint8Array)
|
||||
expect(key.length).toBe(32)
|
||||
})
|
||||
|
||||
it('is deterministic — same inputs always produce the same key', async () => {
|
||||
const key1 = await deriveSecretKey('uid-abc', 'salt-xyz')
|
||||
const key2 = await deriveSecretKey('uid-abc', 'salt-xyz')
|
||||
expect(key1).toEqual(key2)
|
||||
})
|
||||
|
||||
it('different UIDs produce different keys', async () => {
|
||||
const key1 = await deriveSecretKey('uid-1', 'same-salt')
|
||||
const key2 = await deriveSecretKey('uid-2', 'same-salt')
|
||||
expect(key1).not.toEqual(key2)
|
||||
})
|
||||
|
||||
it('different salts produce different keys', async () => {
|
||||
const key1 = await deriveSecretKey('same-uid', 'salt-a')
|
||||
const key2 = await deriveSecretKey('same-uid', 'salt-b')
|
||||
expect(key1).not.toEqual(key2)
|
||||
})
|
||||
|
||||
it('handles empty UID string', async () => {
|
||||
const key = await deriveSecretKey('', 'some-salt')
|
||||
expect(key).toBeInstanceOf(Uint8Array)
|
||||
expect(key.length).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
// ── generateDeviceKey ────────────────────────────────────────────────────
|
||||
|
||||
describe('generateDeviceKey', () => {
|
||||
it('returns a 32-byte Uint8Array', async () => {
|
||||
const key = await generateDeviceKey()
|
||||
expect(key).toBeInstanceOf(Uint8Array)
|
||||
expect(key.length).toBe(32)
|
||||
})
|
||||
|
||||
it('generates unique keys each time (random)', async () => {
|
||||
const key1 = await generateDeviceKey()
|
||||
const key2 = await generateDeviceKey()
|
||||
// Two random 256-bit arrays should be different
|
||||
expect(key1).not.toEqual(key2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── encryptEntry / decryptEntry ──────────────────────────────────────────
|
||||
|
||||
describe('encryptEntry / decryptEntry', () => {
|
||||
const secretKey = new Uint8Array(32).fill(1)
|
||||
|
||||
it('roundtrip: decrypting an encrypted entry returns original content', async () => {
|
||||
const content = 'Today I am grateful for my family.'
|
||||
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe(content)
|
||||
})
|
||||
|
||||
it('returns base64-encoded strings for ciphertext and nonce', async () => {
|
||||
const { ciphertext, nonce } = await encryptEntry('test content', secretKey)
|
||||
expect(() => Buffer.from(ciphertext, 'base64')).not.toThrow()
|
||||
expect(() => Buffer.from(nonce, 'base64')).not.toThrow()
|
||||
// Valid base64 only contains these characters
|
||||
expect(ciphertext).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
|
||||
it('handles empty string content', async () => {
|
||||
const { ciphertext, nonce } = await encryptEntry('', secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe('')
|
||||
})
|
||||
|
||||
it('handles unicode and emoji content', async () => {
|
||||
const content = 'Grateful for 🌟 life! नमस्ते 日本語'
|
||||
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe(content)
|
||||
})
|
||||
|
||||
it('handles very long content (10,000 chars)', async () => {
|
||||
const content = 'a'.repeat(10000)
|
||||
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe(content)
|
||||
})
|
||||
|
||||
it('different plaintext produces different ciphertext', async () => {
|
||||
const { ciphertext: ct1 } = await encryptEntry('hello world', secretKey)
|
||||
const { ciphertext: ct2 } = await encryptEntry('goodbye world', secretKey)
|
||||
expect(ct1).not.toBe(ct2)
|
||||
})
|
||||
|
||||
it('decryptEntry throws "Failed to decrypt entry" on bad ciphertext', async () => {
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
|
||||
crypto_secretbox_open_easy: () => { throw new Error('invalid mac') },
|
||||
}) as never)
|
||||
await expect(decryptEntry('notvalidbase64!!', 'nonce', secretKey))
|
||||
.rejects.toThrow('Failed to decrypt entry')
|
||||
})
|
||||
|
||||
it('decryptEntry throws when called with wrong key', async () => {
|
||||
// Simulate libsodium authentication failure with wrong key
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium)
|
||||
.mockResolvedValueOnce(createMockSodium() as never) // for encrypt
|
||||
.mockResolvedValueOnce(createMockSodium({ // for decrypt (wrong key throws)
|
||||
crypto_secretbox_open_easy: () => { throw new Error('incorrect key') },
|
||||
}) as never)
|
||||
|
||||
const { ciphertext, nonce } = await encryptEntry('secret', secretKey)
|
||||
const wrongKey = new Uint8Array(32).fill(99)
|
||||
await expect(decryptEntry(ciphertext, nonce, wrongKey))
|
||||
.rejects.toThrow('Failed to decrypt entry')
|
||||
})
|
||||
})
|
||||
|
||||
// ── encryptSecretKey / decryptSecretKey ──────────────────────────────────
|
||||
|
||||
describe('encryptSecretKey / decryptSecretKey', () => {
|
||||
it('roundtrip: encrypts and decrypts master key back to original', async () => {
|
||||
const masterKey = new Uint8Array(32).fill(99)
|
||||
const deviceKey = new Uint8Array(32).fill(55)
|
||||
const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
|
||||
const decrypted = await decryptSecretKey(ciphertext, nonce, deviceKey)
|
||||
expect(decrypted).toEqual(masterKey)
|
||||
})
|
||||
|
||||
it('returns base64 strings', async () => {
|
||||
const masterKey = new Uint8Array(32).fill(1)
|
||||
const deviceKey = new Uint8Array(32).fill(2)
|
||||
const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
|
||||
expect(typeof ciphertext).toBe('string')
|
||||
expect(typeof nonce).toBe('string')
|
||||
})
|
||||
|
||||
it('decryptSecretKey throws "Failed to decrypt secret key" on wrong device key', async () => {
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
|
||||
crypto_secretbox_open_easy: () => { throw new Error('decryption failed') },
|
||||
}) as never)
|
||||
await expect(decryptSecretKey('fakeciphertext', 'fakenonce', new Uint8Array(32)))
|
||||
.rejects.toThrow('Failed to decrypt secret key')
|
||||
})
|
||||
})
|
||||
|
||||
// ── salt functions ───────────────────────────────────────────────────────
|
||||
|
||||
describe('generateSalt / saveSalt / getSalt', () => {
|
||||
it('generateSalt returns the constant salt string', () => {
|
||||
expect(generateSalt()).toBe('grateful-journal-v1')
|
||||
})
|
||||
|
||||
it('generateSalt is idempotent', () => {
|
||||
expect(generateSalt()).toBe(generateSalt())
|
||||
})
|
||||
|
||||
it('saveSalt and getSalt roundtrip', () => {
|
||||
saveSalt('my-custom-salt')
|
||||
expect(getSalt()).toBe('my-custom-salt')
|
||||
})
|
||||
|
||||
it('getSalt returns null when nothing stored', () => {
|
||||
localStorage.clear()
|
||||
expect(getSalt()).toBeNull()
|
||||
})
|
||||
|
||||
it('overwriting salt replaces old value', () => {
|
||||
saveSalt('first')
|
||||
saveSalt('second')
|
||||
expect(getSalt()).toBe('second')
|
||||
})
|
||||
})
|
||||
|
||||
// ── device key localStorage ──────────────────────────────────────────────
|
||||
|
||||
describe('saveDeviceKey / getDeviceKey / clearDeviceKey', () => {
|
||||
it('saves and retrieves device key correctly', async () => {
|
||||
const key = new Uint8Array(32).fill(7)
|
||||
await saveDeviceKey(key)
|
||||
const retrieved = await getDeviceKey()
|
||||
expect(retrieved).toEqual(key)
|
||||
})
|
||||
|
||||
it('returns null when no device key is stored', async () => {
|
||||
localStorage.clear()
|
||||
const key = await getDeviceKey()
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('clearDeviceKey removes the stored key', async () => {
|
||||
const key = new Uint8Array(32).fill(7)
|
||||
await saveDeviceKey(key)
|
||||
clearDeviceKey()
|
||||
const retrieved = await getDeviceKey()
|
||||
expect(retrieved).toBeNull()
|
||||
})
|
||||
|
||||
it('overwriting device key stores the new key', async () => {
|
||||
const key1 = new Uint8Array(32).fill(1)
|
||||
const key2 = new Uint8Array(32).fill(2)
|
||||
await saveDeviceKey(key1)
|
||||
await saveDeviceKey(key2)
|
||||
const retrieved = await getDeviceKey()
|
||||
expect(retrieved).toEqual(key2)
|
||||
})
|
||||
})
|
||||
})
|
||||
3
src/__tests__/setup.ts
Normal file
3
src/__tests__/setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Global test setup
|
||||
// happy-dom provides: crypto (Web Crypto API), localStorage, sessionStorage, IndexedDB, fetch
|
||||
// No additional polyfills needed for this project
|
||||
Reference in New Issue
Block a user