fix: keep composers mounted while the app lock saves them, and tell the service worker nothing with the lock off

This commit is contained in:
Linus Rath
2026-11-03 13:37:15 +01:00
parent fb3c82eed5
commit 9c36801f5e
3 changed files with 39 additions and 15 deletions
@@ -122,28 +122,37 @@ describe('PopoutHost', () => {
expect(JSON.stringify(heard)).not.toContain('Bearer main');
});
it('gives no credentials while the app is locked, also to a pop-out that never heard of the lock', async () => {
it('gives no credentials while the app is locked, to a pop-out that never heard of the lock', async () => {
render(<PopoutHost />);
await settle();
setLockedHere(true);
const channel = openPopoutChannel(() => {});
const opener = await createSessionOpener();
channel?.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey });
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey });
await settle();
channel?.close();
expect(answers()).toEqual([]);
await expect(requestWindowSession({}, 100)).resolves.toBeNull();
setLockedHere(false);
await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ activeAccountId: 'alice' });
popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey });
await vi.waitFor(() => expect(answers()).toHaveLength(1));
});
it('a locked pop-out takes no session from the cookies either', async () => {
it('a locked pop-out doesn\'t ask for a session', async () => {
setLockedHere(true);
await expect(requestWindowSession({}, 100)).resolves.toBeNull();
setLockedHere(false);
expect(heard.filter((message) => message.type === 'session-request')).toEqual([]);
});
it('a locked pop-out takes no session from the cookies either, and asks nobody', async () => {
const fetched = vi.fn(async () => new Response('{}'));
vi.stubGlobal('fetch', fetched);
setLockedHere(true);
try {
await expect(useAuthStore.getState().startSharedSession({})).resolves.toBe('no-session');
await expect(useAuthStore.getState().startSharedSession({ accountId: 'alice' })).resolves.toBe('no-session');
} finally {
setLockedHere(false);
}
expect(fetched).not.toHaveBeenCalled();
expect(useAuthStore.getState().client).not.toBeNull();
});
it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => {
+21 -5
View File
@@ -197,6 +197,14 @@ describe('while locked', () => {
expect(isAppLockedNow()).toBe(true);
});
it('a running page isn\'t held back while it saves: composers stay mounted until the reload', async () => {
await enableAppLock(PIN);
let holdDuringSave: Promise<void> | null | undefined;
registerComposeFlush(async () => { holdDuringSave = appLockHold(); });
await lockedAndReloaded();
expect(holdDuringSave).toBeNull();
});
it('notifications go by it: the check the OS code asks says locked', async () => {
await enableAppLock(PIN);
asNewDocument();
@@ -286,15 +294,23 @@ describe('locking', () => {
});
describe('the store when a tab inherits it without the key', () => {
it('locks everywhere instead of opening or writing an unwrapped key', async () => {
it('asks for the lock instead of opening, or writing a key outside the layer', async () => {
await enableAppLock(PIN);
// This tab has no key: the one that had it went.
resetAppLockForTests();
(window.navigator.locks.query as ReturnType<typeof vi.fn>).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] });
(window.navigator.locks.request as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
bootAppLock();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
const layers = () => appLockKeyLayers();
expect(layers).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' }));
// A new account can't get a key record outside the layer either.
await vi.waitFor(() => expect(getAppLockPhase()).toBe('open'));
const onKeyLocked = vi.fn();
configureStore({ ...hooks, onKeyLocked });
await expect(getStore().ensureOpen('new-account', { apiUrl: 'x' } as never).then(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
await getStore().ensureOpen('new-account');
})).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' });
expect(onKeyLocked).toHaveBeenCalled();
expect(await getAccountRecord('new-account')).toBeNull();
expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(false);
});
it('a new account after unlock gets its key under the lock', async () => {
+1 -2
View File
@@ -366,7 +366,6 @@ async function applyLockHere(): Promise<void> {
const wasRunning = phase === 'open';
setLockedHere(true);
markDocument(true);
hold ??= newHold();
setPhase('locking');
tellDesktopLocked();
tellServiceWorker('locked');
@@ -699,7 +698,7 @@ export function startAppLock(): () => void {
startIdle();
if (!heldKey) watchHolder();
}
tellServiceWorker(readAppLockFile().on ? (isAppLockedNow() ? 'locked' : 'unlocked') : 'off');
if (readAppLockFile().on) tellServiceWorker(isAppLockedNow() ? 'locked' : 'unlocked');
return () => {
channel?.close();
window.removeEventListener('storage', onStorage);