605 lines
18 KiB
JavaScript

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,
exportSelected,
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 exportSelected(null)
})
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 export with specific group IDs', async () => {
const group2 = createGroup('Personal')
await addGroup(group2)
const enc = await encrypt('personal-secret', await deriveKey('test', generateSalt()))
await addEntry(createEntry({
title: 'Netflix',
encryptedPassword: enc,
groupId: group2.id,
}))
// Export only the Work group
const workGroupId = exportData.groups.find(g => g.name === 'Work').id
const workOnly = await exportSelected([workGroupId])
expect(workOnly.groups).toHaveLength(1)
expect(workOnly.groups[0].name).toBe('Work')
expect(workOnly.entries).toHaveLength(1)
expect(workOnly.entries[0].title).toBe('GitHub')
// Export both groups
const allGroups = await getGroups()
const bothGroupIds = [workGroupId, group2.id]
const both = await exportSelected(bothGroupIds)
expect(both.entries).toHaveLength(2)
expect(both.groups).toHaveLength(2)
})
it('should export with empty array (full export)', async () => {
const emptyExport = await exportSelected([])
expect(emptyExport.entries).toHaveLength(1)
const nonTrashGroups = emptyExport.groups.filter(g => g.id !== TRASH_GROUP_ID)
expect(nonTrashGroups).toHaveLength(1)
})
it('should export only ungrouped entries with empty string groupId', async () => {
const enc = await encrypt('ungrouped-secret', await deriveKey('test', generateSalt()))
await addEntry(createEntry({
title: 'Ungrouped Entry',
encryptedPassword: enc,
groupId: '',
}))
const ungroupedExport = await exportSelected([''])
const ungroupedEntries = ungroupedExport.entries.filter(e => e.groupId === '')
expect(ungroupedEntries).toHaveLength(1)
expect(ungroupedEntries[0].title).toBe('Ungrouped Entry')
// Should not include the Work group's entry
expect(ungroupedExport.entries).not.toEqual(expect.arrayContaining([expect.objectContaining({ title: '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)
})
})