Add tests and trim down inconsistencies in the code.

This commit is contained in:
Timothy Farrell 2026-05-17 21:46:07 +00:00
parent 5a240b081d
commit 57d3ce99d8
15 changed files with 1312 additions and 387 deletions

View File

@ -17,16 +17,18 @@ src/
│ ├── EntryList.svelte # Credential entries grid
│ ├── EntryForm.svelte # Create/edit credential form
│ ├── EntryDetail.svelte # View single entry (copy password/username)
│ ├── PasswordGenerator.svelte# Configurable password generator
│ └── ImportExport.svelte # JSON import/export with merge/replace
│ ├── ImportExport.svelte # JSON import/export with merge/replace
│ └── SettingsDialog.svelte # Auto-lock and tab-switch settings
├── lib/
│ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores
│ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation
│ ├── autofocus.js # Svelte action for autofocus on mount
│ └── stores/
│ ├── app.svelte.js # AppStore: $state(isUnlocked, encryptionKey, salt)
│ ├── security.svelte.js # Auto-lock timer, visibility change, beforeunload cleanup
│ └── search.svelte.js # Reactive search state
│ ├── search.svelte.js # Reactive search state
│ └── settings.svelte.js # Reactive settings (auto-lock minutes, tab-switch lock)
```
## Key Design Decisions
@ -37,6 +39,7 @@ src/
- **Single-file build**`vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon.
- **IndexedDB via `idb`** — Three stores: `entries`, `groups`, `meta`. Only `encryptedPassword` is encrypted at rest; titles, usernames, URLs, and notes are plaintext for searchability.
- **PBKDF2 key derivation** — 600,000 iterations, SHA-256, 256-bit AES-GCM key.
- **`GROUP_COLORS` exported from schema.js** — Shared between `createGroup()` and `Sidebar.svelte`.
## Encryption Flow
@ -59,14 +62,26 @@ Password verification uses a test payload (random string encrypted at vault crea
## Testing
- Framework: **Vitest** with **jsdom** environment
- Test setup: `tests/setup.js` (fake-indexeddb polyfill)
- Test files: `tests/lib/stores/*.test.js`
- Test setup: `tests/setup.js` (fake-indexeddb polyfill, Web Crypto API polyfill)
- Test files:
- `tests/lib/crypto/crypto.test.js` — Password generation, key derivation, encrypt/decrypt, verify, test payload, base64 utils
- `tests/lib/models/schema.test.js` — ID generation, entry/group CRUD, validation, trash group
- `tests/lib/storage/db.test.js` — Vault meta, settings, groups CRUD, entries CRUD, search, trash, export/import
- `tests/lib/stores/app.test.js` — AppStore state and lockVault
- `tests/lib/stores/search.test.js` — SearchStore query, group filter, clear
- `tests/lib/stores/settings.test.js` — SettingsStore defaults, load, save
- `tests/lib/stores/security.test.js` — Auto-lock timer, visibility change, beforeunload, activity reset
- Run with `npm run test` or `npm run test:run`
## Security Notes
- Only `encryptedPassword` is encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB.
- `testPlaintext` for password verification is stored unencrypted in the `meta` store.
- Auto-lock triggers on tab visibility change and 5-minute inactivity.
- Auto-lock triggers on tab visibility change and configurable inactivity timer (default 5 min).
- Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections.
## Known Bug Fixes
- `base64ToUint8Array` was used in `db.js` `importAll()` but never imported — now imported from `crypto.js`.
- `handlePermanentDelete()` in `EntryDetail.svelte` called `moveToTrash()` + `emptyTrash()` (wiping ALL trash) — now uses `deleteEntry()` directly.

View File

@ -4,11 +4,11 @@ An offline-first password manager that runs entirely in your browser. No server,
## Features
- **AES-256-GCM encryption** — All credentials encrypted with a key derived from your master password via PBKDF2 (100,000 iterations). The key exists only in memory.
- **AES-256-GCM encryption** — All credentials encrypted with a key derived from your master password via PBKDF2 (600,000 iterations). The key exists only in memory.
- **Zero network calls** — Works from `file://` or `localhost`. No APIs, no analytics, no telemetry.
- **Group management** — Organize entries into color-coded groups. Create, rename, delete.
- **Full-text search** — Instant search across title, username, URL, and notes.
- **Password generator**Configurable length (464), character types, custom exclusions, strength indicator.
- **Password generator**Random password generation using Web Crypto API with configurable length and character types.
- **Copy to clipboard** — One-click copy with 15-second auto-clear.
- **JSON import/export** — Export your entire vault as encrypted JSON. Import with merge or replace mode.
- **Auto-lock** — Vault locks automatically on tab switch, visibility change, or 5-minute inactivity timer.
@ -40,7 +40,7 @@ The single-file output is handled by [`vite-plugin-singlefile`](https://www.npmj
```
Master Password ──PBKDF2──→ 256-bit Key ──AES-GCM──→ Encrypted Credential
(100k iters)
(600k iters)
└── Salt stored in IndexedDB (not encrypted)
```
@ -66,7 +66,7 @@ Master Password ──PBKDF2──→ 256-bit Key ──AES-GCM──→ Encrypt
| Clipboard leakage | Auto-clear after 15 seconds |
| Tab left open | Auto-lock on visibility change (tab switch) |
| Database tampering | Passwords encrypted at rest with AES-256-GCM |
| Brute force | PBKDF2 with 100,000 iterations slows offline attacks |
| Brute force | PBKDF2 with 600,000 iterations slows offline attacks |
### Known limitations

63
dist/index.html vendored
View File

@ -5216,8 +5216,8 @@ async function encrypt(plaintext, key) {
*/
async function decrypt(encryptedJson, key) {
const { iv, ciphertext } = JSON.parse(encryptedJson);
const ciphertextBuffer = base64ToUint8Array$1(ciphertext);
const ivBuffer = base64ToUint8Array$1(iv);
const ciphertextBuffer = base64ToUint8Array(ciphertext);
const ivBuffer = base64ToUint8Array(iv);
const decrypted = await crypto.subtle.decrypt({
name: "AES-GCM",
iv: ivBuffer
@ -5262,7 +5262,7 @@ function uint8ArrayToBase64(buffer) {
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function base64ToUint8Array$1(base64) {
function base64ToUint8Array(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
@ -5520,6 +5520,14 @@ async function updateEntry(entry) {
await (await getDb()).put("entries", entry);
}
/**
* Delete an entry.
* @param {string} entryId
* @returns {Promise<void>}
*/
async function deleteEntry(entryId) {
await (await getDb()).delete("entries", entryId);
}
/**
* Get a single entry by ID.
* @param {string} entryId
* @returns {Promise<CredentialEntry | undefined>}
@ -5783,9 +5791,9 @@ function autofocus(node, condition = true) {
}
//#endregion
//#region src/components/LockScreen.svelte
var root_1$8 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-7sq1ct" role="alert"> </div>`);
var root_1$7 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-7sq1ct" role="alert"> </div>`);
var root_2$6 = /* @__PURE__ */ from_html(`<div class="form-group"><label for="confirm-password">Confirm Password</label> <input id="confirm-password" type="password" placeholder="Confirm master password" autocomplete="new-password"/></div>`);
var root$8 = /* @__PURE__ */ from_html(`<div class="lock-screen svelte-7sq1ct"><div class="lock-card svelte-7sq1ct"><div class="lock-icon svelte-7sq1ct">🔐</div> <h1 class="svelte-7sq1ct">Password Vault</h1> <p class="subtitle svelte-7sq1ct"> </p> <!> <form class="lock-form svelte-7sq1ct"><div class="form-group"><label for="master-password">Master Password</label> <input id="master-password" type="password" placeholder="Enter master password" autocomplete="current-password"/></div> <!> <button type="submit" class="btn btn-primary w-full"> </button></form> <p class="hint svelte-7sq1ct"> </p></div></div>`);
var root$7 = /* @__PURE__ */ from_html(`<div class="lock-screen svelte-7sq1ct"><div class="lock-card svelte-7sq1ct"><div class="lock-icon svelte-7sq1ct">🔐</div> <h1 class="svelte-7sq1ct">Password Vault</h1> <p class="subtitle svelte-7sq1ct"> </p> <!> <form class="lock-form svelte-7sq1ct"><div class="form-group"><label for="master-password">Master Password</label> <input id="master-password" type="password" placeholder="Enter master password" autocomplete="current-password"/></div> <!> <button type="submit" class="btn btn-primary w-full"> </button></form> <p class="hint svelte-7sq1ct"> </p></div></div>`);
function LockScreen($$anchor, $$props) {
push($$props, true);
let masterPassword = /* @__PURE__ */ state("");
@ -5847,14 +5855,14 @@ function LockScreen($$anchor, $$props) {
set(masterPassword, "");
set(confirmPassword, "");
}
var div = root$8();
var div = root$7();
var div_1 = child(div);
var p = sibling(child(div_1), 4);
var text = child(p, true);
reset(p);
var node = sibling(p, 2);
var consequent = ($$anchor) => {
var div_2 = root_1$8();
var div_2 = root_1$7();
var text_1 = child(div_2, true);
reset(div_2);
template_effect(() => set_text(text_1, get(error)));
@ -5968,7 +5976,7 @@ var root_4$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlm
var root_5$4 = /* @__PURE__ */ from_html(`<button></button>`);
var root_3$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group svelte-181dlmc"><label for="group-name" class="svelte-181dlmc">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal" class="svelte-181dlmc"/></div> <div class="form-group svelte-181dlmc"><span class="field-label svelte-181dlmc">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary svelte-181dlmc"> </button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root_6$4 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root$7 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="trash-section svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button></div> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full svelte-181dlmc">+ New Group</button></div> <!> <!></div>`);
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="trash-section svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button></div> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full svelte-181dlmc">+ New Group</button></div> <!> <!></div>`);
function Sidebar($$anchor, $$props) {
push($$props, true);
let groups = /* @__PURE__ */ state(proxy([]));
@ -5995,23 +6003,6 @@ function Sidebar($$anchor, $$props) {
function canDrop(groupId) {
return groupId !== search.activeGroupId && !isTrashGroup(groupId);
}
const GROUP_COLORS = [
"#6c63ff",
"#e5484d",
"#34d399",
"#fbbf24",
"#3b82f6",
"#ec4899",
"#8b5cf6",
"#14b8a6",
"#f97316",
"#06b6d4",
"#a855f7",
"#ef4444",
"#22c55e",
"#eab308",
"#6366f1"
];
async function loadData() {
await ensureTrashGroup();
set(groups, await getGroups(), true);
@ -6063,7 +6054,7 @@ function Sidebar($$anchor, $$props) {
set(groupError, "Failed to delete group: " + e.message);
}
}
var div = root$7();
var div = root$6();
var div_1 = sibling(child(div), 2);
var input = child(div_1);
remove_input_defaults(input);
@ -6236,7 +6227,7 @@ function Sidebar($$anchor, $$props) {
delegate(["input", "click"]);
//#endregion
//#region src/components/EntryList.svelte
var root_1$7 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`);
var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`);
var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu4"> </div>`);
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
@ -6246,7 +6237,7 @@ var root_9$1 = /* @__PURE__ */ from_html(`<span class="drag-handle svelte-13s7gu
var root_10$1 = /* @__PURE__ */ from_html(`<td class="svelte-13s7gu4"><button class="btn btn-ghost btn-sm restore-btn svelte-13s7gu4" title="Restore entry">↩️</button></td>`);
var root_8$2 = /* @__PURE__ */ from_html(`<tr><td class="svelte-13s7gu4"><!> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-notes truncate svelte-13s7gu4"> </span></td><!></tr>`);
var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th><th class="svelte-13s7gu4">Notes</th><!></tr></thead><tbody></tbody></table>`, 1);
var root$6 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
function EntryList($$anchor, $$props) {
push($$props, true);
let entries = /* @__PURE__ */ state(proxy([]));
@ -6285,10 +6276,10 @@ function EntryList($$anchor, $$props) {
search.refreshTrigger;
loadEntries();
});
var div = root$6();
var div = root$5();
var node = child(div);
var consequent = ($$anchor) => {
append($$anchor, root_1$7());
append($$anchor, root_1$6());
};
var consequent_1 = ($$anchor) => {
var div_2 = root_2$4();
@ -6443,7 +6434,7 @@ function EntryList($$anchor, $$props) {
delegate(["click"]);
//#endregion
//#region src/components/EntryDetail.svelte
var root_1$6 = /* @__PURE__ */ from_html(`<div class="toast svelte-dssgjx"> </div>`);
var root_1$5 = /* @__PURE__ */ from_html(`<div class="toast svelte-dssgjx"> </div>`);
var root_2$3 = /* @__PURE__ */ from_html(`<div class="loading svelte-dssgjx">Loading...</div>`);
var root_3$3 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-dssgjx"> </div>`);
var root_4$3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-dssgjx">Entry not found</div>`);
@ -6455,7 +6446,7 @@ var root_10 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="
var root_11 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Move to Trash</h3> <p class="svelte-dssgjx">Move "<strong> </strong>" to the trash? You can restore it later.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_12 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Delete Forever</h3> <p class="svelte-dssgjx">Permanently delete "<strong> </strong>"? This cannot be undone.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_5$2 = /* @__PURE__ */ from_html(`<div class="detail-card svelte-dssgjx"><div class="detail-header svelte-dssgjx"><h2 class="svelte-dssgjx"> </h2> <div class="header-actions svelte-dssgjx"><!></div></div> <div class="detail-fields svelte-dssgjx"><!> <div class="detail-field"><span class="field-label svelte-dssgjx">Password</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy password">📋</button></div></div> <!> <!></div> <div class="detail-meta svelte-dssgjx"><span class="text-xs text-muted"> </span> <span class="text-xs text-muted"> </span></div></div> <!> <!>`, 1);
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-detail"><!> <!></div>`);
var root$4 = /* @__PURE__ */ from_html(`<div class="entry-detail"><!> <!></div>`);
function EntryDetail($$anchor, $$props) {
push($$props, true);
let entry = /* @__PURE__ */ state(null);
@ -6523,8 +6514,7 @@ function EntryDetail($$anchor, $$props) {
async function handlePermanentDelete() {
set(deleting, true);
try {
await moveToTrash($$props.entryId);
await emptyTrash();
await deleteEntry($$props.entryId);
$$props.onBack();
} catch (e) {
set(error, "Failed to permanently delete: " + e.message);
@ -6532,10 +6522,10 @@ function EntryDetail($$anchor, $$props) {
set(deleting, false);
set(showPermanentDeleteConfirm, false);
}
var div = root$5();
var div = root$4();
var node = child(div);
var consequent = ($$anchor) => {
var div_1 = root_1$6();
var div_1 = root_1$5();
var text_1 = child(div_1, true);
reset(div_1);
template_effect(() => set_text(text_1, get(toast)));
@ -6747,7 +6737,6 @@ function EntryDetail($$anchor, $$props) {
pop();
}
delegate(["click"]);
delegate(["click"]);
//#endregion
//#region src/components/EntryForm.svelte
var root_1$4 = /* @__PURE__ */ from_html(`<div class="loading svelte-pafazm">Loading...</div>`);

View File

@ -1,5 +1,5 @@
<script>
import { getEntryById, moveToTrash, emptyTrash } from '../lib/storage/db.js'
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
@ -80,11 +80,9 @@
}
async function handlePermanentDelete() {
// Move to trash first (if not already), then empty trash
deleting = true
try {
await moveToTrash(entryId)
await emptyTrash()
await deleteEntry(entryId)
onBack()
} catch (e) {
error = 'Failed to permanently delete: ' + e.message

View File

@ -4,8 +4,7 @@
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js'
import PasswordGenerator from './PasswordGenerator.svelte'
import { autofocus } from '../lib/autofocus.js'
import { autofocus } from '../lib/autofocus.js'
let { entryId, onSave, onCancel } = $props()

View File

@ -1,301 +0,0 @@
<script>
import { generatePassword } from '../lib/crypto/crypto.js'
let length = $state(16)
let uppercase = $state(true)
let lowercase = $state(true)
let digits = $state(true)
let symbols = $state(true)
let exclude = $state('')
let generated = $state('')
let strength = $state('')
let toast = $state('')
let toastTimer = null
// Generate on mount and whenever settings change
// $effect auto-tracks all reactive reads
$effect(() => {
// Reading these values registers them as dependencies
const _l = length
const _u = uppercase
const _lo = lowercase
const _d = digits
const _s = symbols
const _e = exclude
regenerate()
})
function regenerate() {
try {
generated = generatePassword({ length, uppercase, lowercase, digits, symbols, exclude })
strength = calculateStrength(generated)
} catch (e) {
generated = ''
strength = ''
}
}
function calculateStrength(password) {
if (!password) return { label: '', color: '' }
let score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (password.length >= 16) score++
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
if (/\d/.test(password)) score++
if (/[^a-zA-Z0-9]/.test(password)) score++
if (score <= 2) return { label: 'Weak', color: 'var(--color-danger)' }
if (score <= 4) return { label: 'Moderate', color: 'var(--color-warning)' }
return { label: 'Strong', color: 'var(--color-success)' }
}
function showToast(message) {
toast = message
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast = '' }, 2000)
}
async function copyPassword() {
try {
await navigator.clipboard.writeText(generated)
showToast('✓ Password copied to clipboard')
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = generated
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
showToast('✓ Password copied')
}
}
</script>
<div class="password-generator">
{#if toast}
<div class="toast">{toast}</div>
{/if}
<div class="generator-card">
<h3>🔑 Password Generator</h3>
<!-- Generated password display -->
<div class="generated-display">
<code class="generated-password">{generated}</code>
<div class="display-actions">
<span class="strength-badge" style="color: {strength.color}">{strength.label}</span>
<button class="btn btn-ghost btn-sm" onclick={copyPassword} title="Copy">📋 Copy</button>
<button class="btn btn-ghost btn-sm" onclick={regenerate} title="Regenerate">🔄</button>
</div>
</div>
<!-- Length slider -->
<div class="form-group">
<div class="slider-header">
<span class="field-label">Length</span>
<span class="slider-value">{length}</span>
</div>
<input
type="range"
min="4"
max="64"
bind:value={length}
class="length-slider"
/>
<div class="slider-marks">
<span>4</span>
<span>16</span>
<span>32</span>
<span>64</span>
</div>
</div>
<!-- Character types -->
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" bind:checked={uppercase} />
<span>Uppercase (A-Z)</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={lowercase} />
<span>Lowercase (a-z)</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={digits} />
<span>Numbers (0-9)</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={symbols} />
<span>Symbols (!@#$...)</span>
</label>
</div>
<!-- Exclude characters -->
<div class="form-group">
<label for="exclude-chars">Exclude characters</label>
<input
id="exclude-chars"
type="text"
bind:value={exclude}
placeholder="e.g. 0OIl1"
/>
</div>
</div>
</div>
<style>
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
color: var(--color-success);
box-shadow: var(--shadow);
z-index: 1000;
}
.generator-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
max-width: 450px;
}
.generator-card h3 {
margin-bottom: 16px;
}
.generated-display {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: 16px;
flex-wrap: wrap;
}
.generated-password {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 1rem;
word-break: break-all;
flex: 1;
min-width: 0;
}
.display-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.strength-badge {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted);
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.slider-value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-primary);
}
.length-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--color-border);
border-radius: 3px;
outline: none;
border: none;
padding: 0;
}
.length-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
}
.length-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: none;
}
.slider-marks {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 4px;
padding: 0 2px;
}
.checkbox-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--radius-sm);
transition: background-color 150ms;
}
.checkbox-label:hover {
background: var(--color-surface-hover);
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.checkbox-label span {
flex: 1;
}
</style>

View File

@ -1,6 +1,6 @@
<script>
import { getGroups, addGroup, updateGroup, deleteGroup, moveEntryToGroup, ensureTrashGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup, isTrashGroup, TRASH_GROUP_NAME, TRASH_GROUP_COLOR } from '../lib/models/schema.js'
import { createGroup, validateGroup, isTrashGroup, TRASH_GROUP_NAME, TRASH_GROUP_COLOR, GROUP_COLORS } from '../lib/models/schema.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
@ -35,12 +35,6 @@
return groupId !== searchStore.activeGroupId && !isTrashGroup(groupId)
}
const GROUP_COLORS = [
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4',
'#a855f7', '#ef4444', '#22c55e', '#eab308', '#6366f1',
]
async function loadData() {
await ensureTrashGroup()
groups = await getGroups()

View File

@ -97,7 +97,7 @@ export function updateEntry(existing, data) {
* @property {string} createdAt - ISO timestamp
*/
const GROUP_COLORS = [
export const GROUP_COLORS = [
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4',
]

View File

@ -12,7 +12,7 @@
*/
import { openDB } from 'idb'
import { deriveKey, decrypt, encrypt } from '../crypto/crypto.js'
import { deriveKey, decrypt, encrypt, base64ToUint8Array } from '../crypto/crypto.js'
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
// Re-export for convenience
@ -371,21 +371,6 @@ export async function moveEntryToGroup(entryId, groupId) {
await db.put('entries', entry)
}
/**
* Count entries per group.
* @returns {Promise<Map<string, number>>}
*/
export async function getEntryCountsByGroup() {
const db = await getDb()
const all = await db.getAll('entries')
const counts = new Map()
for (const entry of all) {
const gid = entry.groupId || ''
counts.set(gid, (counts.get(gid) || 0) + 1)
}
return counts
}
// ========================
// Import / Export
// ========================

View File

@ -81,10 +81,3 @@ export function stopAutoLock() {
window.removeEventListener('beforeunload', clearKeyOnExit)
}
/**
* Check if Web Crypto API is available.
* @returns {boolean}
*/
export function isCryptoAvailable() {
return typeof crypto !== 'undefined' && crypto.subtle !== undefined
}

View File

@ -1,5 +1,15 @@
import { describe, it, expect } from 'vitest'
import { generatePassword } from '../../../src/lib/crypto/crypto.js'
import {
generatePassword,
generateSalt,
deriveKey,
encrypt,
decrypt,
verifyPassword,
createTestPayload,
uint8ArrayToBase64,
base64ToUint8Array,
} from '../../../src/lib/crypto/crypto.js'
describe('generatePassword', () => {
it('should produce different passwords on consecutive calls', () => {
@ -7,7 +17,6 @@ describe('generatePassword', () => {
for (let i = 0; i < 50; i++) {
passwords.add(generatePassword({ length: 16 }))
}
// With 50 calls and 94^16 possible values, all should be unique
expect(passwords.size).toBe(50)
})
@ -41,3 +50,203 @@ describe('generatePassword', () => {
.toThrow()
})
})
describe('generateSalt', () => {
it('should return a 16-byte Uint8Array', () => {
const salt = generateSalt()
expect(salt).toBeInstanceOf(Uint8Array)
expect(salt.length).toBe(16)
})
it('should produce different salts on consecutive calls', () => {
const salt1 = generateSalt()
const salt2 = generateSalt()
expect(salt1).not.toEqual(salt2)
})
})
describe('deriveKey', () => {
it('should derive a key from password and salt', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
expect(key).toBeDefined()
expect(key.type).toBe('secret')
expect(key.algorithm.name).toBe('AES-GCM')
expect(key.algorithm.length).toBe(256)
})
it('should derive the same key for same password and salt', async () => {
const salt = generateSalt()
const key1 = await deriveKey('test-password', salt)
const key2 = await deriveKey('test-password', salt)
// Encrypt with key1, decrypt with key2 — should work if keys are identical
const encrypted = await encrypt('hello', key1)
const decrypted = await decrypt(encrypted, key2)
expect(decrypted).toBe('hello')
})
it('should derive different keys for different passwords', async () => {
const salt = generateSalt()
const key1 = await deriveKey('password1', salt)
const key2 = await deriveKey('password2', salt)
const encrypted = await encrypt('hello', key1)
// Should fail with wrong key
await expect(decrypt(encrypted, key2)).rejects.toThrow()
})
})
describe('encrypt / decrypt', () => {
it('should encrypt and decrypt a string', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
const plaintext = 'my-secret-password'
const encrypted = await encrypt(plaintext, key)
expect(typeof encrypted).toBe('string')
const decrypted = await decrypt(encrypted, key)
expect(decrypted).toBe(plaintext)
})
it('should produce different ciphertext for same plaintext', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
const encrypted1 = await encrypt('hello', key)
const encrypted2 = await encrypt('hello', key)
expect(encrypted1).not.toBe(encrypted2)
})
it('should decrypt to correct value despite different ciphertext', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
const encrypted1 = await encrypt('hello', key)
const encrypted2 = await encrypt('hello', key)
expect(await decrypt(encrypted1, key)).toBe('hello')
expect(await decrypt(encrypted2, key)).toBe('hello')
})
it('should handle empty string', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
const encrypted = await encrypt('', key)
const decrypted = await decrypt(encrypted, key)
expect(decrypted).toBe('')
})
it('should handle unicode characters', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
const plaintext = 'Привет мир! 你好世界 🌍'
const encrypted = await encrypt(plaintext, key)
const decrypted = await decrypt(encrypted, key)
expect(decrypted).toBe(plaintext)
})
it('should fail with wrong key', async () => {
const salt1 = generateSalt()
const key1 = await deriveKey('correct', salt1)
const salt2 = generateSalt()
const key2 = await deriveKey('wrong', salt2)
const encrypted = await encrypt('secret', key1)
await expect(decrypt(encrypted, key2)).rejects.toThrow()
})
it('should return valid JSON', async () => {
const salt = generateSalt()
const key = await deriveKey('test-password', salt)
const encrypted = await encrypt('hello', key)
const parsed = JSON.parse(encrypted)
expect(parsed).toHaveProperty('iv')
expect(parsed).toHaveProperty('ciphertext')
})
})
describe('verifyPassword', () => {
it('should return true for correct password', async () => {
const salt = generateSalt()
const key = await deriveKey('correct-password', salt)
const testPlaintext = 'test-value'
const testEncrypted = await encrypt(testPlaintext, key)
const result = await verifyPassword('correct-password', salt, testEncrypted, testPlaintext)
expect(result).toBe(true)
})
it('should return false for wrong password', async () => {
const salt = generateSalt()
const key = await deriveKey('correct-password', salt)
const testPlaintext = 'test-value'
const testEncrypted = await encrypt(testPlaintext, key)
const result = await verifyPassword('wrong-password', salt, testEncrypted, testPlaintext)
expect(result).toBe(false)
})
it('should return false for empty password', async () => {
const salt = generateSalt()
const key = await deriveKey('correct-password', salt)
const testPlaintext = 'test-value'
const testEncrypted = await encrypt(testPlaintext, key)
const result = await verifyPassword('', salt, testEncrypted, testPlaintext)
expect(result).toBe(false)
})
})
describe('createTestPayload', () => {
it('should return salt, testEncrypted, and testPlaintext', async () => {
const payload = await createTestPayload('my-password')
expect(payload.salt).toBeInstanceOf(Uint8Array)
expect(payload.salt.length).toBe(16)
expect(typeof payload.testEncrypted).toBe('string')
expect(typeof payload.testPlaintext).toBe('string')
expect(payload.testPlaintext).toMatch(/^vault_test_/)
})
it('should produce a payload that verifies correctly', async () => {
const { salt, testEncrypted, testPlaintext } = await createTestPayload('my-password')
const result = await verifyPassword('my-password', salt, testEncrypted, testPlaintext)
expect(result).toBe(true)
})
it('should produce different payloads on consecutive calls', async () => {
const p1 = await createTestPayload('pass')
const p2 = await createTestPayload('pass')
expect(p1.salt).not.toEqual(p2.salt)
expect(p1.testPlaintext).not.toBe(p2.testPlaintext)
})
})
describe('uint8ArrayToBase64 / base64ToUint8Array', () => {
it('should roundtrip a Uint8Array', () => {
const original = new Uint8Array([0, 127, 255, 10, 20, 30, 128, 200])
const encoded = uint8ArrayToBase64(original)
const decoded = base64ToUint8Array(encoded)
expect(decoded).toEqual(original)
})
it('should roundtrip an empty array', () => {
const original = new Uint8Array(0)
const encoded = uint8ArrayToBase64(original)
const decoded = base64ToUint8Array(encoded)
expect(decoded).toEqual(original)
})
it('should produce valid base64 string', () => {
const original = new Uint8Array([65, 66, 67])
const encoded = uint8ArrayToBase64(original)
expect(typeof encoded).toBe('string')
expect(encoded).toBe('QUJD')
})
})

View File

@ -0,0 +1,244 @@
import { describe, it, expect } from 'vitest'
import {
generateId,
createEntry,
updateEntry,
createGroup,
validateEntry,
validateGroup,
createTrashGroup,
isTrashGroup,
TRASH_GROUP_ID,
TRASH_GROUP_NAME,
TRASH_GROUP_COLOR,
} from '../../../src/lib/models/schema.js'
describe('generateId', () => {
it('should generate unique IDs', () => {
const ids = new Set()
for (let i = 0; i < 100; i++) {
ids.add(generateId())
}
expect(ids.size).toBe(100)
})
it('should produce sortable IDs (timestamp-based)', () => {
const id1 = generateId()
// Small delay to ensure different timestamp
// IDs are base36 timestamps + random, so they should be lexicographically ordered
expect(id1).toMatch(/^[a-z0-9]+_[0-9a-f]{8}$/)
})
it('should contain underscore separator', () => {
const id = generateId()
const parts = id.split('_')
expect(parts).toHaveLength(2)
expect(parts[1]).toHaveLength(8)
})
})
describe('createEntry', () => {
it('should create an entry with required fields', () => {
const entry = createEntry({
title: 'GitHub',
encryptedPassword: 'encrypted-blob',
})
expect(entry.id).toBeDefined()
expect(entry.title).toBe('GitHub')
expect(entry.encryptedPassword).toBe('encrypted-blob')
expect(entry.createdAt).toBeDefined()
expect(entry.updatedAt).toBe(entry.createdAt)
})
it('should trim title and optional fields', () => {
const entry = createEntry({
title: ' GitHub ',
username: ' user@test.com ',
url: ' https://github.com ',
notes: ' some notes ',
encryptedPassword: 'encrypted-blob',
})
expect(entry.title).toBe('GitHub')
expect(entry.username).toBe('user@test.com')
expect(entry.url).toBe('https://github.com')
expect(entry.notes).toBe('some notes')
})
it('should set defaults for optional fields', () => {
const entry = createEntry({
title: 'Test',
encryptedPassword: 'encrypted-blob',
})
expect(entry.username).toBe('')
expect(entry.url).toBe('')
expect(entry.notes).toBe('')
expect(entry.groupId).toBe('')
expect(entry.tags).toEqual([])
})
it('should accept groupId and tags', () => {
const entry = createEntry({
title: 'Test',
encryptedPassword: 'encrypted-blob',
groupId: 'group-123',
tags: ['work', 'important'],
})
expect(entry.groupId).toBe('group-123')
expect(entry.tags).toEqual(['work', 'important'])
})
})
describe('updateEntry', () => {
it('should preserve id and createdAt', () => {
const existing = createEntry({
title: 'GitHub',
encryptedPassword: 'old-encrypted',
})
const updated = updateEntry(existing, { title: 'GitHub Pro' })
expect(updated.id).toBe(existing.id)
expect(updated.createdAt).toBe(existing.createdAt)
// updatedAt should be >= createdAt (ISO strings are lexicographically comparable)
expect(updated.updatedAt >= existing.updatedAt).toBe(true)
})
it('should update only specified fields', () => {
const existing = createEntry({
title: 'GitHub',
username: 'user',
encryptedPassword: 'old-encrypted',
url: 'https://github.com',
notes: 'old notes',
})
const updated = updateEntry(existing, { title: 'GitHub Pro' })
expect(updated.title).toBe('GitHub Pro')
expect(updated.username).toBe('user')
expect(updated.encryptedPassword).toBe('old-encrypted')
expect(updated.url).toBe('https://github.com')
expect(updated.notes).toBe('old notes')
})
it('should trim updated string fields', () => {
const existing = createEntry({
title: 'GitHub',
encryptedPassword: 'encrypted',
})
const updated = updateEntry(existing, { title: ' GitHub Pro ', notes: ' new notes ' })
expect(updated.title).toBe('GitHub Pro')
expect(updated.notes).toBe('new notes')
})
it('should handle undefined fields (no-op)', () => {
const existing = createEntry({
title: 'GitHub',
encryptedPassword: 'encrypted',
})
const updated = updateEntry(existing, { title: undefined })
expect(updated.title).toBe('GitHub')
})
})
describe('createGroup', () => {
it('should create a group with name and color', () => {
const group = createGroup('Work', '#ff0000')
expect(group.id).toBeDefined()
expect(group.name).toBe('Work')
expect(group.color).toBe('#ff0000')
expect(group.createdAt).toBeDefined()
})
it('should assign a random color when none provided', () => {
const group = createGroup('Personal')
expect(group.color).toMatch(/^#[0-9a-f]{6}$/)
})
it('should trim group name', () => {
const group = createGroup(' Work ')
expect(group.name).toBe('Work')
})
})
describe('validateEntry', () => {
it('should pass with valid data', () => {
const result = validateEntry({ title: 'GitHub', encryptedPassword: 'encrypted' })
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
it('should fail with empty title', () => {
const result = validateEntry({ title: '', encryptedPassword: 'encrypted' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Title is required')
})
it('should fail with whitespace-only title', () => {
const result = validateEntry({ title: ' ', encryptedPassword: 'encrypted' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Title is required')
})
it('should fail with missing encryptedPassword', () => {
const result = validateEntry({ title: 'GitHub' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Password is required')
})
it('should report multiple errors', () => {
const result = validateEntry({ title: '' })
expect(result.valid).toBe(false)
expect(result.errors.length).toBe(2)
})
})
describe('validateGroup', () => {
it('should pass with valid name', () => {
const result = validateGroup('Work')
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
it('should fail with empty name', () => {
const result = validateGroup('')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Group name is required')
})
it('should fail with whitespace-only name', () => {
const result = validateGroup(' ')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Group name is required')
})
it('should fail with name over 50 characters', () => {
const result = validateGroup('a'.repeat(51))
expect(result.valid).toBe(false)
expect(result.errors).toContain('Group name must be 50 characters or less')
})
it('should pass with exactly 50 characters', () => {
const result = validateGroup('a'.repeat(50))
expect(result.valid).toBe(true)
})
})
describe('createTrashGroup', () => {
it('should return a group with fixed trash ID', () => {
const group = createTrashGroup()
expect(group.id).toBe(TRASH_GROUP_ID)
expect(group.name).toBe(TRASH_GROUP_NAME)
expect(group.color).toBe(TRASH_GROUP_COLOR)
expect(group.createdAt).toBeDefined()
})
})
describe('isTrashGroup', () => {
it('should return true for trash group ID', () => {
expect(isTrashGroup(TRASH_GROUP_ID)).toBe(true)
})
it('should return false for other IDs', () => {
expect(isTrashGroup('group-123')).toBe(false)
expect(isTrashGroup('')).toBe(false)
expect(isTrashGroup('__trash')).toBe(false)
expect(isTrashGroup('__trash__extra')).toBe(false)
})
})

View File

@ -0,0 +1,554 @@
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)
})
})

View File

@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.svelte.js'
import { app } from '../../../src/lib/stores/app.svelte.js'
import { settings } from '../../../src/lib/stores/settings.svelte.js'
// Reset singleton state before each test
function resetState() {
app.isUnlocked = false
app.encryptionKey = null
app.salt = null
settings.autoLockMinutes = 5
settings.lockOnTabSwitch = true
}
beforeEach(() => {
resetState()
stopAutoLock()
})
afterEach(() => {
stopAutoLock()
resetState()
})
describe('startAutoLock / stopAutoLock', () => {
it('should register and clean up activity listeners', () => {
// startAutoLock registers event listeners
startAutoLock()
// stopAutoLock should remove them
stopAutoLock()
// No error means cleanup worked
expect(true).toBe(true)
})
it('should register and clean up visibility listener', () => {
startAutoLock()
stopAutoLock()
expect(true).toBe(true)
})
it('should register and clean up beforeunload listener', () => {
startAutoLock()
stopAutoLock()
expect(true).toBe(true)
})
it('should not throw when called multiple times', () => {
startAutoLock()
startAutoLock()
stopAutoLock()
stopAutoLock()
expect(true).toBe(true)
})
})
describe('auto-lock behavior', () => {
it('should lock vault after inactivity timeout', async () => {
vi.useFakeTimers()
settings.autoLockMinutes = 1
app.isUnlocked = true
app.encryptionKey = { mock: 'key' }
startAutoLock()
// Wait for the timer (1 minute = 60000ms)
vi.advanceTimersByTime(60000)
expect(app.isUnlocked).toBe(false)
expect(app.encryptionKey).toBe(null)
vi.useRealTimers()
})
it('should lock on tab switch when lockOnTabSwitch is true', async () => {
app.isUnlocked = true
app.encryptionKey = { mock: 'key' }
settings.lockOnTabSwitch = true
startAutoLock()
// Simulate visibility change
Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
expect(app.isUnlocked).toBe(false)
expect(app.encryptionKey).toBe(null)
})
it('should not lock on tab switch when lockOnTabSwitch is false', async () => {
app.isUnlocked = true
app.encryptionKey = { mock: 'key' }
settings.lockOnTabSwitch = false
startAutoLock()
Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
expect(app.isUnlocked).toBe(true)
expect(app.encryptionKey).toEqual({ mock: 'key' })
})
it('should clear key on beforeunload', async () => {
app.isUnlocked = true
app.encryptionKey = { mock: 'key' }
startAutoLock()
window.dispatchEvent(new Event('beforeunload'))
expect(app.encryptionKey).toBe(null)
})
it('should reset timer on user activity', async () => {
vi.useFakeTimers()
settings.autoLockMinutes = 1
app.isUnlocked = true
app.encryptionKey = { mock: 'key' }
startAutoLock()
// Tick 45 seconds — not enough to trigger
vi.advanceTimersByTime(45000)
expect(app.isUnlocked).toBe(true)
// Simulate user activity (keydown resets timer)
window.dispatchEvent(new Event('keydown'))
// Tick another 45 seconds — should still be unlocked (timer was reset)
vi.advanceTimersByTime(45000)
expect(app.isUnlocked).toBe(true)
// Tick the remaining 15+ seconds
vi.advanceTimersByTime(20000)
expect(app.isUnlocked).toBe(false)
vi.useRealTimers()
})
it('should not lock when already locked', async () => {
app.isUnlocked = false
settings.lockOnTabSwitch = true
startAutoLock()
Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
expect(app.isUnlocked).toBe(false)
})
})

View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { settings } from '../../../src/lib/stores/settings.svelte.js'
import { saveSetting, getSetting } from '../../../src/lib/storage/db.js'
beforeEach(() => {
// Reset settings to defaults
settings.autoLockMinutes = 5
settings.lockOnTabSwitch = true
})
describe('SettingsStore', () => {
describe('default values', () => {
it('should have default autoLockMinutes of 5', () => {
expect(settings.autoLockMinutes).toBe(5)
})
it('should have default lockOnTabSwitch of true', () => {
expect(settings.lockOnTabSwitch).toBe(true)
})
})
describe('load()', () => {
it('should load persisted settings from IndexedDB', async () => {
await saveSetting('autoLockMinutes', 10)
await saveSetting('lockOnTabSwitch', false)
await settings.load()
expect(settings.autoLockMinutes).toBe(10)
expect(settings.lockOnTabSwitch).toBe(false)
})
it('should fall back to defaults for missing keys', async () => {
// Remove any previously saved settings
await saveSetting('autoLockMinutes', undefined)
await saveSetting('lockOnTabSwitch', undefined)
await settings.load()
expect(settings.autoLockMinutes).toBe(5)
expect(settings.lockOnTabSwitch).toBe(true)
})
it('should fall back to defaults for partially missing keys', async () => {
await saveSetting('autoLockMinutes', 15)
await saveSetting('lockOnTabSwitch', undefined)
await settings.load()
expect(settings.autoLockMinutes).toBe(15)
expect(settings.lockOnTabSwitch).toBe(true) // default
})
it('should handle string values from IndexedDB', async () => {
await saveSetting('autoLockMinutes', '10')
await saveSetting('lockOnTabSwitch', 'true')
await settings.load()
expect(settings.autoLockMinutes).toBe(10) // coerced to number
expect(settings.lockOnTabSwitch).toBe(true) // coerced to boolean
})
})
describe('save()', () => {
it('should persist settings to IndexedDB', async () => {
settings.autoLockMinutes = 15
settings.lockOnTabSwitch = false
await settings.save()
// Verify by loading back
const loadedMinutes = await getSetting('autoLockMinutes')
const loadedTabSwitch = await getSetting('lockOnTabSwitch')
expect(loadedMinutes).toBe(15)
expect(loadedTabSwitch).toBe(false)
})
it('should persist different values', async () => {
settings.autoLockMinutes = 30
settings.lockOnTabSwitch = true
await settings.save()
const loadedMinutes = await getSetting('autoLockMinutes')
const loadedTabSwitch = await getSetting('lockOnTabSwitch')
expect(loadedMinutes).toBe(30)
expect(loadedTabSwitch).toBe(true)
})
})
})