Add tests and trim down inconsistencies in the code.
This commit is contained in:
parent
5a240b081d
commit
57d3ce99d8
27
AGENTS.md
27
AGENTS.md
@ -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.
|
||||
|
||||
@ -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 (4–64), 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
63
dist/index.html
vendored
@ -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>`);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -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
|
||||
// ========================
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
244
tests/lib/models/schema.test.js
Normal file
244
tests/lib/models/schema.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
554
tests/lib/storage/db.test.js
Normal file
554
tests/lib/storage/db.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
155
tests/lib/stores/security.test.js
Normal file
155
tests/lib/stores/security.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
91
tests/lib/stores/settings.test.js
Normal file
91
tests/lib/stores/settings.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user