refactor(ext/state): lift vault_locked intercept into shared/state.ts (Plan C Phase 4)
The session-lost intercept lived in vault.ts's local sendMessage; both surfaces
now consume it through the shared sendMessage() wrapper. On a vault_locked
response to any non-bypassed request, the wrapper calls host.navigate('locked').
The vault host's navigate gains a 'locked' branch (it shows its lock screen off
state.unlocked); the popup's navigate already handles 'locked'. vault.ts routes
ctx.sendMessage through the shared wrapper and registers a plain transport as
host.sendMessage, so internal RPCs keep the intercept without recursion.
grep -c vault_locked vault.ts == 0. New state-vault-locked.test.ts (TDD, 6 cases).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { registerHost, __resetHostForTests, sendMessage } from '../state';
|
||||||
|
import type { StateHost } from '../state';
|
||||||
|
import type { Response } from '../messages';
|
||||||
|
|
||||||
|
function makeHost(response: { ok: boolean; error?: string }): StateHost {
|
||||||
|
return {
|
||||||
|
getState: () => ({ view: 'list' } as never),
|
||||||
|
setState: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
sendMessage: vi.fn().mockResolvedValue(response as Response),
|
||||||
|
escapeHtml: (s) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: () => false,
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('shared/state sendMessage vault_locked intercept', () => {
|
||||||
|
beforeEach(() => __resetHostForTests());
|
||||||
|
|
||||||
|
it('navigates to the lock screen on a vault_locked response', async () => {
|
||||||
|
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||||
|
registerHost(host);
|
||||||
|
await sendMessage({ type: 'list_items' });
|
||||||
|
expect(host.navigate).toHaveBeenCalledWith(
|
||||||
|
'locked',
|
||||||
|
expect.objectContaining({ error: expect.any(String) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT intercept the unlock request itself', async () => {
|
||||||
|
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||||
|
registerHost(host);
|
||||||
|
await sendMessage({ type: 'unlock', passphrase: 'x' });
|
||||||
|
expect(host.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT intercept is_unlocked', async () => {
|
||||||
|
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||||
|
registerHost(host);
|
||||||
|
await sendMessage({ type: 'is_unlocked' });
|
||||||
|
expect(host.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not intercept a successful response', async () => {
|
||||||
|
const host = makeHost({ ok: true });
|
||||||
|
registerHost(host);
|
||||||
|
await sendMessage({ type: 'list_items' });
|
||||||
|
expect(host.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not intercept a non-vault_locked error', async () => {
|
||||||
|
const host = makeHost({ ok: false, error: 'something_else' });
|
||||||
|
registerHost(host);
|
||||||
|
await sendMessage({ type: 'list_items' });
|
||||||
|
expect(host.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the response unchanged', async () => {
|
||||||
|
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||||
|
registerHost(host);
|
||||||
|
const resp = await sendMessage({ type: 'list_items' });
|
||||||
|
expect(resp).toEqual({ ok: false, error: 'vault_locked' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,13 +50,33 @@ export function navigate(view: View, extras?: Partial<PopupState>): void {
|
|||||||
host.navigate(view, extras);
|
host.navigate(view, extras);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requests that must NOT trigger the lock screen on a vault_locked response:
|
||||||
|
// they run during cold start / unlock, before a session exists, so a
|
||||||
|
// vault_locked here is expected rather than a lost session.
|
||||||
|
const VAULT_LOCKED_BYPASS: ReadonlySet<Request['type']> = new Set([
|
||||||
|
'unlock', 'is_unlocked',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 4 will add a vault_locked intercept here. For now, this is a pure
|
* Dispatches a request to the service worker and intercepts the `vault_locked`
|
||||||
* pass-through so the signature is stable for Phase 4 to fill.
|
* response. MV3 evicts the service worker after ~30s idle, wiping the in-memory
|
||||||
|
* session/manifest; the next RPC comes back `vault_locked`. Any surface (popup
|
||||||
|
* or vault tab) that gets that on a non-bypassed request treats it as "session
|
||||||
|
* lost" and navigates to the lock screen so the user can re-enter their
|
||||||
|
* passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4
|
||||||
|
* so both surfaces share one channel.
|
||||||
*/
|
*/
|
||||||
export async function sendMessage(request: Request): Promise<Response> {
|
export async function sendMessage(request: Request): Promise<Response> {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.sendMessage(request);
|
const response = await host.sendMessage(request);
|
||||||
|
if (
|
||||||
|
!response.ok &&
|
||||||
|
response.error === 'vault_locked' &&
|
||||||
|
!VAULT_LOCKED_BYPASS.has(request.type)
|
||||||
|
) {
|
||||||
|
host.navigate('locked', { error: 'Session expired — please unlock again.' });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHtml(s: string): string {
|
export function escapeHtml(s: string): string {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
/// vault tab's pane area.
|
/// vault tab's pane area.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
import { registerHost } from '../shared/state';
|
import { registerHost, sendMessage } from '../shared/state';
|
||||||
import { type ErrorCta } from '../shared/error-copy';
|
import { type ErrorCta } from '../shared/error-copy';
|
||||||
import {
|
import {
|
||||||
type VaultController, type VaultState, type VaultView,
|
type VaultController, type VaultState, type VaultView,
|
||||||
@@ -27,32 +27,12 @@ import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vaul
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function sendMessage(request: Request): Promise<Response> {
|
// Plain transport to the service worker, registered as the host's sendMessage.
|
||||||
|
// The shared sendMessage() wrapper (shared/state.ts) layers the session-lost
|
||||||
|
// → lock-screen intercept on top of this for every UI RPC.
|
||||||
|
function postToServiceWorker(request: Request): Promise<Response> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
chrome.runtime.sendMessage(request, (response: Response) => resolve(response));
|
||||||
// MV3 service workers are evicted after ~30s idle, which wipes the
|
|
||||||
// in-memory session/manifest/gitHost. The fullscreen tab stays open
|
|
||||||
// and has no signal that the SW restarted — the next RPC just comes
|
|
||||||
// back `vault_locked`. Treat that as "session lost" and force the
|
|
||||||
// lock screen so the user can re-enter their passphrase. Skip for
|
|
||||||
// is_unlocked / unlock themselves to avoid loops on cold start.
|
|
||||||
if (
|
|
||||||
response &&
|
|
||||||
!response.ok &&
|
|
||||||
response.error === 'vault_locked' &&
|
|
||||||
request.type !== 'is_unlocked' &&
|
|
||||||
request.type !== 'unlock' &&
|
|
||||||
state.unlocked
|
|
||||||
) {
|
|
||||||
state.unlocked = false;
|
|
||||||
state.selectedId = null;
|
|
||||||
state.selectedItem = null;
|
|
||||||
state.entries = [];
|
|
||||||
state.error = 'Session expired — please unlock again.';
|
|
||||||
render(ctx);
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +95,17 @@ registerHost({
|
|||||||
renderPane(ctx);
|
renderPane(ctx);
|
||||||
},
|
},
|
||||||
navigate: (view, extras) => {
|
navigate: (view, extras) => {
|
||||||
|
if (view === 'locked') {
|
||||||
|
// Session lost (SW evicted mid-session). The vault shows its lock
|
||||||
|
// screen off state.unlocked, so flip it and drop the in-memory data.
|
||||||
|
state.unlocked = false;
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
state.entries = [];
|
||||||
|
state.error = (extras?.error as string | undefined) ?? null;
|
||||||
|
render(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Object.assign(state, { view, error: null, loading: false, ...extras });
|
Object.assign(state, { view, error: null, loading: false, ...extras });
|
||||||
setHash(view as VaultView);
|
setHash(view as VaultView);
|
||||||
applyShellViewClass(ctx);
|
applyShellViewClass(ctx);
|
||||||
@@ -122,7 +113,7 @@ registerHost({
|
|||||||
if (state.view === 'list') renderListPane(ctx);
|
if (state.view === 'list') renderListPane(ctx);
|
||||||
renderPane(ctx);
|
renderPane(ctx);
|
||||||
},
|
},
|
||||||
sendMessage,
|
sendMessage: postToServiceWorker,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
popOutToTab: () => {},
|
popOutToTab: () => {},
|
||||||
isInTab: () => true,
|
isInTab: () => true,
|
||||||
|
|||||||
Reference in New Issue
Block a user