Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 400 additions & 0 deletions apps/extension/__tests__/vault-session.test.js

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions apps/extension/__tests__/vault-ui.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Tests for the vault UI's pure helpers — search and master-password strength.
* @module __tests__/vault-ui.test
*/

import { describe, it, expect, vi } from 'vitest';

vi.mock('webextension-polyfill', () => ({ default: {} }));

import { filterItems } from '../src/popup/components/VaultPanel.jsx';
import { assessPassword } from '../src/popup/components/vault/VaultUnlock.jsx';

const items = [
{
id: '1',
type: 'login',
name: 'GitHub',
notes: '',
login: { username: 'anthony', uris: [{ uri: 'https://github.com' }] },
},
{
id: '2',
type: 'card',
name: 'Travel Visa',
notes: 'expires soon',
card: { cardholderName: 'A Ettinger', brand: 'Visa' },
},
{
id: '3',
type: 'identity',
name: 'Home',
notes: '',
identity: { email: 'me@example.com', firstName: 'Ada', lastName: 'Lovelace' },
},
];

describe('filterItems', () => {
it('returns everything for an empty query', () => {
expect(filterItems(items, '')).toHaveLength(3);
expect(filterItems(items, ' ')).toHaveLength(3);
});

it('matches on name, case-insensitively', () => {
expect(filterItems(items, 'github')).toHaveLength(1);
expect(filterItems(items, 'GITHUB')[0].id).toBe('1');
});

it('matches on a login username', () => {
expect(filterItems(items, 'anthony')[0].id).toBe('1');
});

it('matches on the website, which is how people look a login up', () => {
expect(filterItems(items, 'github.com')[0].id).toBe('1');
});

it('matches on an identity email and name', () => {
expect(filterItems(items, 'lovelace')[0].id).toBe('3');
expect(filterItems(items, 'me@example')[0].id).toBe('3');
});

it('matches on a card brand and cardholder', () => {
expect(filterItems(items, 'visa').map((i) => i.id)).toContain('2');
expect(filterItems(items, 'ettinger')[0].id).toBe('2');
});

it('matches on notes', () => {
expect(filterItems(items, 'expires')[0].id).toBe('2');
});

it('returns nothing when nothing matches', () => {
expect(filterItems(items, 'zzzz')).toEqual([]);
});

it('does not blow up on items missing field groups', () => {
const sparse = [{ id: '9', type: 'note', name: 'Just a note' }];
expect(filterItems(sparse, 'note')).toHaveLength(1);
expect(filterItems(sparse, 'nothing')).toHaveLength(0);
});
});

describe('assessPassword', () => {
it('says nothing for an empty password', () => {
expect(assessPassword('')).toMatchObject({ score: 0, label: '' });
});

it('calls out a password that is simply too short', () => {
const result = assessPassword('Ab1!xy');
expect(result.label).toBe('Too short');
expect(result.hint).toMatch(/at least 12/);
});

it('rates a long mixed password highly', () => {
const result = assessPassword('correct-horse-Battery-9-staple!');
expect(result.score).toBeGreaterThanOrEqual(4);
expect(['Good', 'Strong']).toContain(result.label);
});

it('rates a long but monotonous password lower than a mixed one', () => {
const plain = assessPassword('aaaaaaaaaaaaaaaaaaaa');
const mixed = assessPassword('aA1!aaaaaaaaaaaaaaaa');
expect(plain.score).toBeLessThan(mixed.score);
});

it('is never negative or above five', () => {
for (const pw of ['', 'a', 'abcdefghijkl', 'aA1!'.repeat(20)]) {
const { score } = assessPassword(pw);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(5);
}
});
});
1 change: 1 addition & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@marksyncr/core": "workspace:*",
"@marksyncr/sources": "workspace:*",
"@marksyncr/types": "workspace:*",
"@marksyncr/vault": "workspace:*",
"@supabase/supabase-js": "^2.47.10",
"fuse.js": "^7.1.0",
"react": "^19.0.0",
Expand Down
80 changes: 80 additions & 0 deletions apps/extension/src/background/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
*/

import browser from 'webextension-polyfill';
import {
initVaultSession,
isVaultLockAlarm,
getVaultStatus,
getVaultPrefs,
setVaultPrefs,
setupVault,
unlock as unlockVaultSession,
lockVault,
recoverVault,
changeMasterPassword,
listItems as listVaultItems,
saveItem as saveVaultItem,
buildItem as buildVaultItem,
trashItem as trashVaultItem,
restoreItem as restoreVaultItem,
destroyItem as destroyVaultItem,
importItems as importVaultItems,
} from './vault-session.js';
import {
initAdblock,
getAdblockStatus,
Expand Down Expand Up @@ -3653,6 +3672,58 @@ browser.runtime.onMessage.addListener((message, sender) => {
case 'SYNC_ADBLOCK_CLOUD':
return syncAdblockFromCloud();

// ----- Vault -----
case 'VAULT_STATUS':
return getVaultStatus();

case 'VAULT_SETUP':
return setupVault(message.payload?.password);

case 'VAULT_UNLOCK':
return unlockVaultSession(message.payload?.password);

case 'VAULT_LOCK':
return lockVault();

case 'VAULT_RECOVER':
return recoverVault(message.payload?.recoveryKey, message.payload?.newPassword);

case 'VAULT_CHANGE_PASSWORD':
return changeMasterPassword(
message.payload?.currentPassword,
message.payload?.newPassword
);

case 'VAULT_LIST':
return listVaultItems({ trash: Boolean(message.payload?.trash) });

case 'VAULT_SAVE_ITEM': {
const { type, fields, existing } = message.payload || {};
const item = buildVaultItem(type, fields || {}, existing);
return saveVaultItem(item);
}

case 'VAULT_TRASH_ITEM':
return trashVaultItem(message.payload?.id);

case 'VAULT_RESTORE_ITEM':
return restoreVaultItem(message.payload?.id);

case 'VAULT_DELETE_ITEM':
return destroyVaultItem(message.payload?.id);

case 'VAULT_IMPORT':
return importVaultItems(message.payload?.items || []);

case 'VAULT_GET_PREFS':
return getVaultPrefs().then((prefs) => ({ success: true, ...prefs }));

case 'VAULT_SET_PREFS':
return setVaultPrefs({ lockMinutes: message.payload?.lockMinutes }).then((prefs) => ({
success: true,
...prefs,
}));

case 'GET_BLOCKED_REQUESTS':
return getBlockedRequests(message.payload?.tabId);

Expand Down Expand Up @@ -3692,6 +3763,9 @@ browser.runtime.onMessage.addListener((message, sender) => {
// Must run synchronously at top level like every other listener below.
initBlockedLog();

// Vault session — hardens session storage so no content script can read the key.
initVaultSession();

// Alarm handler - registered synchronously for Firefox MV3 compatibility
browser.alarms.onAlarm.addListener(async (alarm) => {
const browserInfo = detectBrowser();
Expand Down Expand Up @@ -3760,6 +3834,12 @@ browser.alarms.onAlarm.addListener(async (alarm) => {
return;
}

if (isVaultLockAlarm(alarm.name)) {
console.log('[MarkSyncr] ⏰ Vault auto-lock triggered');
await lockVault();
return;
}

if (alarm.name === TOKEN_REFRESH_ALARM_NAME) {
console.log('[MarkSyncr] ⏰ Token refresh alarm triggered');

Expand Down
Loading
Loading