Merge feature/fullscreen-ux-phase-2a: smart-input affordances
Phase 2A of the fullscreen UX redesign — 8 form-level smart-input affordances (URL fill-from-tab + hostname chip, group autocomplete, password reveal + strength bar, TOTP live preview + QR decode, notes monospace toggle), shared between popup and fullscreen vault tabs via the new extension/src/shared/form-affordances/ module set. CLI parity: - relicario rate <passphrase> (zxcvbn score / guess estimate) - relicario completions <SHELL> (bash/zsh/fish via clap_complete) - --group <TAB> dynamic enumeration via .relicario/groups.cache (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1) - --totp-qr <path> on add login + edit (rqrr decode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// --- Mocks (must be declared before `route` is imported so the router's
|
||||
// `import * as vault` / `import * as session` resolve to these doubles) ---
|
||||
@@ -921,3 +921,120 @@ describe('parse_lastpass_csv / import_lastpass_commit sender check', () => {
|
||||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- get_active_tab_url ---
|
||||
|
||||
describe('get_active_tab_url', () => {
|
||||
let originalChrome: any;
|
||||
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
|
||||
afterEach(() => { (globalThis as any).chrome = originalChrome; });
|
||||
|
||||
it('get_active_tab_url returns active tab url + title', async () => {
|
||||
// happy-dom does not provide chrome.tabs; stub it.
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'https://github.com/login', title: 'Sign in to GitHub' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ url: 'https://github.com/login', title: 'Sign in to GitHub' });
|
||||
});
|
||||
|
||||
it('get_active_tab_url returns null for chrome:// pages', async () => {
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'chrome://newtab/', title: 'New Tab' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toBeNull();
|
||||
});
|
||||
|
||||
it('get_active_tab_url returns null for view-source: URLs', async () => {
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'view-source:https://github.com/login', title: 'View Source' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- list_groups ---
|
||||
|
||||
describe('list_groups', () => {
|
||||
it('list_groups returns deduplicated sorted groups from manifest', async () => {
|
||||
const state = makeState();
|
||||
state.manifest = {
|
||||
schema_version: 2,
|
||||
items: {
|
||||
a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, // no group
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ groups: ['personal', 'work'] });
|
||||
});
|
||||
|
||||
it('list_groups returns empty array when manifest is null', async () => {
|
||||
const state = makeState();
|
||||
state.manifest = null;
|
||||
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// --- preview_totp_from_secret ---
|
||||
|
||||
describe('preview_totp_from_secret', () => {
|
||||
let originalChrome: any;
|
||||
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
|
||||
afterEach(() => { (globalThis as any).chrome = originalChrome; });
|
||||
|
||||
it('returns code for valid base32', async () => {
|
||||
const state = makeState();
|
||||
state.wasm = {
|
||||
totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }),
|
||||
};
|
||||
const resp = await route(
|
||||
{ type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any,
|
||||
state, makePopupSender(),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 });
|
||||
// Verify a transient TotpConfig was passed (sha1, 6 digits, 30s)
|
||||
const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]);
|
||||
expect(cfgArg.algorithm).toBe('sha1');
|
||||
expect(cfgArg.digits).toBe(6);
|
||||
expect(cfgArg.period_seconds).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects invalid base32', async () => {
|
||||
const state = makeState();
|
||||
state.wasm = { totp_compute: vi.fn() };
|
||||
const resp = await route(
|
||||
{ type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any,
|
||||
state, makePopupSender(),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toMatch(/invalid/i);
|
||||
expect(state.wasm.totp_compute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import { base32Decode } from '../../shared/base32';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
@@ -146,6 +147,52 @@ export async function handle(
|
||||
case 'rate_passphrase':
|
||||
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
||||
|
||||
case 'get_active_tab_url': {
|
||||
const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {
|
||||
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (t) => resolve(t));
|
||||
});
|
||||
const tab = tabs[0];
|
||||
if (!tab?.url) return { ok: true, data: null };
|
||||
// Filter out chrome:// and extension URLs — autofill doesn't apply.
|
||||
if (/^(chrome|chrome-extension|chrome-search|moz-extension|edge|edge-extension|about|file|view-source|data|devtools|javascript):/i.test(tab.url)) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
return { ok: true, data: { url: tab.url, title: tab.title ?? '' } };
|
||||
}
|
||||
|
||||
case 'list_groups': {
|
||||
if (!state.manifest) return { ok: true, data: { groups: [] } };
|
||||
const set = new Set<string>();
|
||||
for (const id in state.manifest.items) {
|
||||
const g = state.manifest.items[id].group;
|
||||
if (g) set.add(g);
|
||||
}
|
||||
return { ok: true, data: { groups: Array.from(set).sort() } };
|
||||
}
|
||||
|
||||
case 'preview_totp_from_secret': {
|
||||
const cleaned = msg.secret_b32.toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
|
||||
if (cleaned.length < 16 || !/^[A-Z2-7]+$/.test(cleaned)) {
|
||||
return { ok: false, error: 'invalid base32 secret' };
|
||||
}
|
||||
let secretBytes: Uint8Array;
|
||||
try {
|
||||
secretBytes = base32Decode(cleaned);
|
||||
} catch (e) {
|
||||
return { ok: false, error: `invalid base32: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
const cfg = {
|
||||
secret: Array.from(secretBytes),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
};
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now));
|
||||
return { ok: true, data: { code: result.code, expires_at: result.expires_at } };
|
||||
}
|
||||
|
||||
case 'generate_password': {
|
||||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||||
return { ok: true, data: { password } };
|
||||
|
||||
Reference in New Issue
Block a user