import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { saveVaultMeta, loadVaultMeta, isVaultInitialized, saveSetting, getSetting, addGroup, updateGroup, deleteGroup, getGroups, getGroupById, ensureTrashGroup, addEntry, updateEntry, deleteEntry, getEntryById, getEntries, searchEntries, moveEntryToGroup, moveToTrash, emptyTrash, restoreEntry, exportAll, importAll, TRASH_GROUP_ID, } from '../../../src/lib/storage/db.js' import { generateSalt, deriveKey, encrypt } from '../../../src/lib/crypto/crypto.js' import { createEntry, createGroup, createTrashGroup } from '../../../src/lib/models/schema.js' const DB_NAME = 'password-vault' async function clearAllData() { // Delete all entries and groups, clear meta to reset state try { // Empty trash first if it has entries await emptyTrash() const entries = await getEntries() for (const e of entries) await deleteEntry(e.id) const groups = await getGroups() for (const g of groups) { if (g.id !== TRASH_GROUP_ID) await deleteGroup(g.id) } // Clear meta keys const salt = generateSalt() await saveVaultMeta(salt, '', '') // Clear settings await saveSetting('autoLockMinutes', undefined) await saveSetting('lockOnTabSwitch', undefined) } catch { // ignore cleanup errors } } beforeEach(async () => clearAllData()) afterEach(async () => clearAllData()) describe('Vault Meta', () => { it('should save and load vault meta', async () => { const salt = generateSalt() await saveVaultMeta(salt, 'encrypted-test', 'test-plaintext') const meta = await loadVaultMeta() expect(meta.salt).toEqual(salt) expect(meta.testEncrypted).toBe('encrypted-test') expect(meta.testPlaintext).toBe('test-plaintext') }) it('should report vault initialized after save', async () => { const salt = generateSalt() await saveVaultMeta(salt, 'encrypted-test', 'test-plaintext') expect(await isVaultInitialized()).toBe(true) }) it('should return salt as Uint8Array when meta exists', async () => { const salt = generateSalt() await saveVaultMeta(salt, 'encrypted-test', 'test-plaintext') const meta = await loadVaultMeta() expect(meta.salt).toBeInstanceOf(Uint8Array) expect(meta.salt.length).toBe(16) }) }) describe('Settings', () => { it('should save and load a setting', async () => { await saveSetting('autoLockMinutes', 10) const value = await getSetting('autoLockMinutes') expect(value).toBe(10) }) it('should return undefined for missing setting', async () => { const value = await getSetting('nonexistent') expect(value).toBeUndefined() }) it('should overwrite existing setting', async () => { await saveSetting('test', 'first') await saveSetting('test', 'second') expect(await getSetting('test')).toBe('second') }) it('should handle different value types', async () => { await saveSetting('bool', true) await saveSetting('string', 'hello') await saveSetting('number', 42) await saveSetting('object', { nested: true }) expect(await getSetting('bool')).toBe(true) expect(await getSetting('string')).toBe('hello') expect(await getSetting('number')).toBe(42) expect(await getSetting('object')).toEqual({ nested: true }) }) }) describe('Groups CRUD', () => { it('should add and retrieve groups', async () => { const group = createGroup('Work') await addGroup(group) const groups = await getGroups() expect(groups).toHaveLength(1) expect(groups[0].name).toBe('Work') }) it('should update a group', async () => { const group = createGroup('Work') await addGroup(group) const updated = { ...group, name: 'Office' } await updateGroup(updated) const groups = await getGroups() expect(groups[0].name).toBe('Office') }) it('should delete a group', async () => { const group = createGroup('Work') await addGroup(group) await deleteGroup(group.id) const groups = await getGroups() expect(groups).toHaveLength(0) }) it('should prevent deleting the Trash group', async () => { await ensureTrashGroup() await expect(deleteGroup(TRASH_GROUP_ID)).rejects.toThrow('Cannot delete the Trash group') }) it('should return groups sorted by createdAt', async () => { const group1 = createGroup('First') await addGroup(group1) // Small delay to ensure different timestamp await new Promise(r => setTimeout(r, 10)) const group2 = createGroup('Second') await addGroup(group2) const groups = (await getGroups()).filter(g => g.id !== TRASH_GROUP_ID) expect(groups[0].name).toBe('First') expect(groups[1].name).toBe('Second') }) it('should get a group by ID', async () => { const group = createGroup('Work') await addGroup(group) const found = await getGroupById(group.id) expect(found.name).toBe('Work') }) it('should return undefined for nonexistent group', async () => { const found = await getGroupById('nonexistent') expect(found).toBeUndefined() }) }) describe('ensureTrashGroup', () => { it('should create trash group if it does not exist', async () => { await ensureTrashGroup() const groups = await getGroups() const trash = groups.find(g => g.id === TRASH_GROUP_ID) expect(trash).toBeDefined() expect(trash.name).toBe('Trash') }) it('should not duplicate trash group on repeated calls', async () => { await ensureTrashGroup() await ensureTrashGroup() const groups = await getGroups() const trashGroups = groups.filter(g => g.id === TRASH_GROUP_ID) expect(trashGroups).toHaveLength(1) }) }) describe('Entries CRUD', () => { let entry beforeEach(async () => { const salt = generateSalt() const key = await deriveKey('test', salt) const encryptedPassword = await encrypt('secret', key) entry = createEntry({ title: 'GitHub', username: 'dev@example.com', encryptedPassword, url: 'https://github.com', notes: 'My GitHub account', }) }) it('should add and retrieve entries', async () => { await addEntry(entry) const entries = await getEntries() expect(entries).toHaveLength(1) expect(entries[0].title).toBe('GitHub') }) it('should update an entry', async () => { await addEntry(entry) const updated = { ...entry, title: 'GitHub Pro' } await updateEntry(updated) const entries = await getEntries() expect(entries[0].title).toBe('GitHub Pro') }) it('should delete an entry', async () => { await addEntry(entry) await deleteEntry(entry.id) const entries = await getEntries() expect(entries).toHaveLength(0) }) it('should get entry by ID', async () => { await addEntry(entry) const found = await getEntryById(entry.id) expect(found.title).toBe('GitHub') }) it('should return undefined for nonexistent entry', async () => { const found = await getEntryById('nonexistent') expect(found).toBeUndefined() }) it('should return entries sorted by updatedAt descending', async () => { await addEntry(entry) await new Promise(r => setTimeout(r, 10)) const entry2 = createEntry({ title: 'Gmail', encryptedPassword: await encrypt('pass', await deriveKey('test', generateSalt())), }) await addEntry(entry2) const entries = await getEntries() expect(entries[0].title).toBe('Gmail') expect(entries[1].title).toBe('GitHub') }) it('should filter entries by groupId', async () => { const group = createGroup('Work') await addGroup(group) const entry1 = createEntry({ title: 'GitHub', encryptedPassword: await encrypt('pass', await deriveKey('test', generateSalt())), groupId: group.id, }) const entry2 = createEntry({ title: 'Gmail', encryptedPassword: await encrypt('pass', await deriveKey('test', generateSalt())), }) await addEntry(entry1) await addEntry(entry2) const filtered = await getEntries({ groupId: group.id }) expect(filtered).toHaveLength(1) expect(filtered[0].title).toBe('GitHub') }) }) describe('searchEntries', () => { let key beforeEach(async () => { const salt = generateSalt() key = await deriveKey('test', salt) const entries = [ createEntry({ title: 'GitHub', username: 'dev', encryptedPassword: await encrypt('pass', key), url: 'https://github.com' }), createEntry({ title: 'Gmail', username: 'user@gmail.com', encryptedPassword: await encrypt('pass', key), notes: 'personal email' }), createEntry({ title: 'Netflix', username: 'streamer', encryptedPassword: await encrypt('pass', key), url: 'https://netflix.com' }), ] for (const e of entries) await addEntry(e) }) it('should search by title', async () => { const results = await searchEntries('github') expect(results).toHaveLength(1) expect(results[0].title).toBe('GitHub') }) it('should search by username', async () => { const results = await searchEntries('gmail') expect(results).toHaveLength(1) expect(results[0].username).toBe('user@gmail.com') }) it('should search by url', async () => { const results = await searchEntries('netflix') expect(results).toHaveLength(1) expect(results[0].url).toBe('https://netflix.com') }) it('should search by notes', async () => { const results = await searchEntries('personal') expect(results).toHaveLength(1) expect(results[0].title).toBe('Gmail') }) it('should be case-insensitive', async () => { const results = await searchEntries('GITHUB') expect(results).toHaveLength(1) }) it('should return empty for no matches', async () => { const results = await searchEntries('nonexistent') expect(results).toHaveLength(0) }) it('should respect groupId filter', async () => { const results = await searchEntries('dev', { groupId: 'nonexistent-group' }) expect(results).toHaveLength(0) }) }) describe('moveEntryToGroup', () => { it('should move an entry to a different group', async () => { const group = createGroup('Work') await addGroup(group) const entry = createEntry({ title: 'Test', encryptedPassword: await encrypt('pass', await deriveKey('test', generateSalt())), }) await addEntry(entry) await moveEntryToGroup(entry.id, group.id) const moved = await getEntryById(entry.id) expect(moved.groupId).toBe(group.id) }) it('should throw for nonexistent entry', async () => { await expect(moveEntryToGroup('nonexistent', 'group-id')).rejects.toThrow('Entry not found') }) }) describe('Trash operations', () => { let entry beforeEach(async () => { const salt = generateSalt() const key = await deriveKey('test', salt) entry = createEntry({ title: 'GitHub', encryptedPassword: await encrypt('secret', key), }) }) it('should move entry to trash', async () => { await addEntry(entry) await moveToTrash(entry.id) const trashed = await getEntryById(entry.id) expect(trashed.groupId).toBe(TRASH_GROUP_ID) }) it('should throw for nonexistent entry', async () => { await expect(moveToTrash('nonexistent')).rejects.toThrow('Entry not found') }) it('should empty trash and delete all trashed entries', async () => { await addEntry(entry) await moveToTrash(entry.id) const count = await emptyTrash() expect(count).toBe(1) const remaining = await getEntries() expect(remaining).toHaveLength(0) }) it('should restore entry from trash', async () => { await addEntry(entry) await moveToTrash(entry.id) await restoreEntry(entry.id, '') const restored = await getEntryById(entry.id) expect(restored.groupId).toBe('') }) it('should restore entry to a specific group', async () => { const group = createGroup('Work') await addGroup(group) await addEntry(entry) await moveToTrash(entry.id) await restoreEntry(entry.id, group.id) const restored = await getEntryById(entry.id) expect(restored.groupId).toBe(group.id) }) it('should throw restore for nonexistent entry', async () => { await expect(restoreEntry('nonexistent')).rejects.toThrow('Entry not found') }) }) describe('Export / Import', () => { let exportData beforeEach(async () => { const salt = generateSalt() const key = await deriveKey('test', salt) // Save vault meta const testPlaintext = 'vault_test_123' const testEncrypted = await encrypt(testPlaintext, key) await saveVaultMeta(salt, testEncrypted, testPlaintext) // Create groups and entries const group = createGroup('Work') await addGroup(group) const enc = await encrypt('secret', key) const entry = createEntry({ title: 'GitHub', username: 'dev', encryptedPassword: enc, groupId: group.id, }) await addEntry(entry) exportData = await exportAll() }) it('should export all data', async () => { expect(exportData.version).toBe(1) expect(exportData.exportedAt).toBeDefined() expect(exportData.meta.salt).toBeDefined() // Includes Trash group + our Work group const nonTrashGroups = exportData.groups.filter(g => g.id !== TRASH_GROUP_ID) expect(nonTrashGroups).toHaveLength(1) expect(nonTrashGroups[0].name).toBe('Work') expect(exportData.entries).toHaveLength(1) expect(exportData.entries[0].title).toBe('GitHub') }) it('should import with merge mode', async () => { await clearAllData() // Set up a new vault const salt = generateSalt() const key = await deriveKey('new-vault', salt) const testPlaintext = 'vault_test_new' const testEncrypted = await encrypt(testPlaintext, key) await saveVaultMeta(salt, testEncrypted, testPlaintext) const result = await importAll(exportData, 'merge', 'test', key) // Groups include Trash + Work from export expect(result.imported.groups).toBeGreaterThanOrEqual(1) expect(result.imported.entries).toBe(1) expect(result.skipped).toBe(0) }) it('should import with replace mode', async () => { await clearAllData() // Set up a new vault with existing data const salt = generateSalt() const key = await deriveKey('new-vault', salt) const testPlaintext = 'vault_test_new' const testEncrypted = await encrypt(testPlaintext, key) await saveVaultMeta(salt, testEncrypted, testPlaintext) // Add an existing entry const enc = await encrypt('existing', key) await addEntry(createEntry({ title: 'Existing', encryptedPassword: enc })) const result = await importAll(exportData, 'replace', 'test', key) expect(result.imported.entries).toBe(1) // Should have replaced existing entry const entries = await getEntries() expect(entries).toHaveLength(1) expect(entries[0].title).toBe('GitHub') }) it('should throw for invalid data format', async () => { const salt = generateSalt() const key = await deriveKey('test', salt) await expect(importAll({ entries: 'invalid' }, 'merge', 'test', key)).rejects.toThrow('Invalid import data format') }) it('should skip entries when source password is missing', async () => { await clearAllData() const salt = generateSalt() const key = await deriveKey('new-vault', salt) const testPlaintext = 'vault_test_new' const testEncrypted = await encrypt(testPlaintext, key) await saveVaultMeta(salt, testEncrypted, testPlaintext) const result = await importAll(exportData, 'merge', '', key) expect(result.skipped).toBe(1) expect(result.imported.entries).toBe(0) }) it('should preserve target vault meta after import', async () => { await clearAllData() const salt = generateSalt() const key = await deriveKey('new-vault', salt) const testPlaintext = 'vault_test_new' const testEncrypted = await encrypt(testPlaintext, key) await saveVaultMeta(salt, testEncrypted, testPlaintext) await importAll(exportData, 'merge', 'test', key) const meta = await loadVaultMeta() expect(meta.testPlaintext).toBe('vault_test_new') // target vault preserved }) it('should skip entries that fail to import', async () => { await clearAllData() const salt = generateSalt() const key = await deriveKey('new-vault', salt) const testPlaintext = 'vault_test_new' const testEncrypted = await encrypt(testPlaintext, key) await saveVaultMeta(salt, testEncrypted, testPlaintext) // Import with wrong password — entries should be skipped const result = await importAll(exportData, 'merge', 'wrong-password', key) expect(result.skipped).toBe(1) expect(result.imported.entries).toBe(0) }) })