import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { createSessionAuthStorage } from '../session-auth';
|
|
class MemoryStorage {
|
values = new Map<string, string>();
|
getItem(key: string) {
|
return this.values.get(key) ?? null;
|
}
|
setItem(key: string, value: string) {
|
this.values.set(key, value);
|
}
|
}
|
|
class Channel extends EventTarget {
|
static peers = new Set<Channel>();
|
constructor() {
|
super();
|
Channel.peers.add(this);
|
}
|
close() {
|
Channel.peers.delete(this);
|
}
|
postMessage(data: unknown) {
|
for (const peer of Channel.peers) {
|
if (peer !== this) queueMicrotask(() => peer.dispatchEvent(new MessageEvent('message', { data })));
|
}
|
}
|
}
|
|
const value = (accessToken: null | string) => JSON.stringify({ accessToken, refreshToken: null });
|
const disposals: (() => void)[] = [];
|
function tab(persistent: MemoryStorage, session = new MemoryStorage(), connected = true) {
|
const events = new EventTarget();
|
const onRemoteChange = vi.fn();
|
const channel = connected ? new Channel() : undefined;
|
const auth = createSessionAuthStorage({
|
key: 'app-core-access',
|
persistent,
|
session,
|
channel: channel as unknown as BroadcastChannel,
|
events: events as Window,
|
onRemoteChange,
|
});
|
disposals.push(auth.dispose);
|
return { auth, session, events, onRemoteChange, channel };
|
}
|
async function start(auth: ReturnType<typeof tab>['auth']) {
|
const ready = auth.start();
|
await vi.advanceTimersByTimeAsync(400);
|
await ready;
|
}
|
|
afterEach(() => {
|
for (const dispose of disposals.splice(0)) dispose();
|
Channel.peers.clear();
|
vi.useRealTimers();
|
});
|
|
describe('session authentication', () => {
|
it('keeps credentials out of persistent storage and restores on refresh', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('test-token'));
|
expect([...persistent.values.values()].join(',')).not.toContain('test-token');
|
a.auth.dispose();
|
const refreshed = tab(persistent, a.session);
|
await start(refreshed.auth);
|
expect(refreshed.auth.getItem()).toBe(value('test-token'));
|
});
|
|
it('shares the same credential with a new tab without another login', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('same-token'));
|
const b = tab(persistent);
|
await start(b.auth);
|
expect(b.auth.getItem()).toBe(value('same-token'));
|
expect(b.onRemoteChange).not.toHaveBeenCalled();
|
expect(a.onRemoteChange).not.toHaveBeenCalled();
|
});
|
|
it('broadcasts login to an already open login page and logout to all peers', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
const b = tab(persistent);
|
await start(a.auth);
|
await start(b.auth);
|
a.auth.setItem('', value('token'));
|
await vi.advanceTimersByTimeAsync(0);
|
expect(b.auth.getItem()).toBe(value('token'));
|
expect(b.onRemoteChange).toHaveBeenCalledTimes(1);
|
a.auth.setItem('', value(null));
|
await vi.advanceTimersByTimeAsync(0);
|
expect(b.auth.getItem()).toBe(value(null));
|
expect(b.onRemoteChange).toHaveBeenCalledTimes(2);
|
expect(a.onRemoteChange).not.toHaveBeenCalled();
|
});
|
|
it('has no credential after all pages close and a fresh session starts', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('token'));
|
a.auth.dispose();
|
const b = tab(persistent);
|
await start(b.auth);
|
expect(b.auth.getItem()).toBeNull();
|
});
|
|
it('rejects a delayed pre-logout response', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('old-token'));
|
const oldRevision = persistent.getItem('app-core-access-revision');
|
a.auth.setItem('', value(null));
|
a.channel!.dispatchEvent(
|
new MessageEvent('message', {
|
data: {
|
type: 'snapshot',
|
snapshot: { revision: oldRevision, value: value('old-token') },
|
},
|
}),
|
);
|
expect(a.auth.getItem()).toBe(value(null));
|
});
|
|
it('invalidates a sleeping tab on focus and does not let stale writes revoke a newer session', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('old-token'));
|
const sleeping = tab(persistent, a.session, false);
|
await start(sleeping.auth);
|
a.auth.setItem('', value('new-token'));
|
const revision = persistent.getItem('app-core-access-revision');
|
sleeping.auth.setItem('', value(null));
|
expect(persistent.getItem('app-core-access-revision')).toBe(revision);
|
sleeping.events.dispatchEvent(new Event('focus'));
|
await vi.advanceTimersByTimeAsync(300);
|
expect(sleeping.auth.getItem()).toBeNull();
|
expect(sleeping.onRemoteChange).toHaveBeenCalledTimes(1);
|
});
|
|
it('rejects a saved session revoked while its tab was closed', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('token'));
|
const saved = new MemoryStorage();
|
saved.values = new Map(a.session.values);
|
a.auth.setItem('', value(null));
|
a.auth.dispose();
|
const restored = tab(persistent, saved);
|
await start(restored.auth);
|
expect(restored.auth.getItem()).toBeNull();
|
});
|
|
it('documents that browser-restored session storage can restore authentication', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const a = tab(persistent);
|
await start(a.auth);
|
a.auth.setItem('', value('token'));
|
a.auth.dispose();
|
const restored = tab(persistent, a.session);
|
await start(restored.auth);
|
expect(restored.auth.getItem()).toBe(value('token'));
|
});
|
|
it('documents that a restored peer can authenticate a login page after refresh', async () => {
|
vi.useFakeTimers();
|
const persistent = new MemoryStorage();
|
const original = tab(persistent);
|
await start(original.auth);
|
original.auth.setItem('', value('previous-browser-session'));
|
const savedSecondTab = new MemoryStorage();
|
savedSecondTab.values = new Map(original.session.values);
|
original.auth.dispose();
|
|
// The first restored page has no session; the second is restored later.
|
const first = tab(persistent);
|
await start(first.auth);
|
expect(first.auth.getAccessToken()).toBeNull();
|
const second = tab(persistent, savedSecondTab);
|
await start(second.auth);
|
expect(second.auth.getAccessToken()).toBe('previous-browser-session');
|
expect(first.auth.getAccessToken()).toBeNull();
|
|
first.auth.dispose();
|
const refreshedFirst = tab(persistent, first.session);
|
await start(refreshedFirst.auth);
|
expect(refreshedFirst.auth.getAccessToken()).toBe('previous-browser-session');
|
});
|
});
|