Files
webmail/lib/__tests__/token-lock.test.ts
T
Linus Rath bb2393cf04 feat: open messages and drafts in their own windows and save all attachments to a folder
An installed app had no title bar of its own, no way to save attachments
except a zip, and "pop out" stayed inside the page. A new window was a
second copy of the app that had to restore the session itself, and on
start-up that evicts any login made without "Remember me" from the list
every window shares.

- Title bar: `display_override` with `window-controls-overlay`, one
  `--titlebar-inset` variable at the page roots and skeletons, and a drag
  strip with the window title. 0 without an overlay, so nothing moves.
- Save all to a folder: `showDirectoryPicker`, asking the device policy
  before the picker and before each file, with the zip as the fallback.
- Pop-out routes `/popout/message/<id>` and `/popout/compose`, on offer in
  an installed app or when the desktop bridge has windows. Hidden in Lite.
- A pop-out borrows the main window's session and never evicts or starts a
  token timer. Every renewal now takes a Web Lock, so two windows can't
  send the same refresh token, which Stalwart 1.0 may treat as a replay.
- A draft moves to its window under the same id. A closing window hands
  unsaved text to the main window to save, since its own request wouldn't
  survive the close.
- Read state, drafts, theme and language follow across windows, and
  `html[data-desktop]` gates the app-like behaviour in `app/desktop.css`.
2026-11-03 06:50:21 +01:00

88 lines
3.1 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { tokenLockName, withTokenLock } from '@/lib/auth/token-lock';
/** Web Locks with one holder at a time and waiters in order, like the browser's. */
function fakeLocks() {
const queues = new Map<string, Promise<unknown>>();
const names: string[] = [];
return {
names,
request(name: string, options: { signal?: AbortSignal }, callback: () => Promise<unknown>) {
names.push(name);
const previous = queues.get(name) ?? Promise.resolve();
const run = new Promise((resolve, reject) => {
let aborted = false;
options.signal?.addEventListener('abort', () => {
aborted = true;
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
void previous.catch(() => {}).then(() => {
if (aborted) return;
Promise.resolve(callback()).then(resolve, reject);
});
});
queues.set(name, run.catch(() => {}));
return run;
},
};
}
function stubLocks(locks: unknown) {
Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
}
afterEach(() => {
stubLocks(undefined);
vi.restoreAllMocks();
});
describe('withTokenLock', () => {
it('runs one renewal per slot at a time, so the second sends the token the first wrote', async () => {
stubLocks(fakeLocks());
const order: string[] = [];
let releaseFirst = () => {};
const first = withTokenLock(1, () => new Promise<string>((resolve) => {
order.push('first starts');
releaseFirst = () => {
order.push('first ends');
resolve('a');
};
}));
const second = withTokenLock(1, async () => {
order.push('second starts');
return 'b';
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(order).toEqual(['first starts']);
releaseFirst();
await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']);
expect(order).toEqual(['first starts', 'first ends', 'second starts']);
});
it('keeps slots apart: each login has its own refresh token', async () => {
const locks = fakeLocks();
stubLocks(locks);
await withTokenLock(0, async () => 'x');
await withTokenLock(2, async () => 'y');
expect(locks.names).toEqual([tokenLockName(0), tokenLockName(2)]);
expect(tokenLockName(0)).not.toBe(tokenLockName(2));
});
it('runs at once without Web Locks', async () => {
await expect(withTokenLock(1, async () => 'done')).resolves.toBe('done');
});
it('goes ahead when another window holds the lock too long, rather than never renewing', async () => {
stubLocks(fakeLocks());
void withTokenLock(1, () => new Promise(() => {}));
await expect(withTokenLock(1, async () => 'went ahead', 10)).resolves.toBe('went ahead');
});
it('hands the work\'s own error to the caller and does not run it twice', async () => {
stubLocks(fakeLocks());
const work = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
await expect(withTokenLock(1, work)).rejects.toThrow('Failed to fetch');
expect(work).toHaveBeenCalledTimes(1);
});
});