605 lines
18 KiB
JavaScript
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)
|
|
})
|
|
})
|