Files
webmail/lib/session-policy/__tests__/session-timer.test.ts
T

144 lines
5.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSessionTimer, type SessionTimerState } from '../session-timer';
import type { SessionLimits } from '../limits';
const START = new Date('2026-10-28T09:00:00Z').getTime();
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const idleOnly = (minutes: number, warnSeconds: number): SessionLimits => ({ idle: { minutes, warnSeconds }, max: null });
function setup(limits: SessionLimits) {
const states: SessionTimerState[] = [];
const onExpire = vi.fn();
const timer = createSessionTimer({ limits, lastActivity: Date.now(), onChange: (s) => states.push(s), onExpire });
return { timer, states, onExpire };
}
describe('session timer', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(START);
});
afterEach(() => {
vi.useRealTimers();
});
it('warns warnSeconds before the idle time runs out, then signs out at zero', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
expect(timer.state()).toMatchObject({ phase: 'active', kind: 'idle', deadline: START + MINUTE });
vi.advanceTimersByTime(30 * SECOND - 1);
expect(timer.state().phase).toBe('active');
vi.advanceTimersByTime(1);
expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'idle', deadline: START + MINUTE });
expect(onExpire).not.toHaveBeenCalled();
vi.advanceTimersByTime(30 * SECOND - 1);
expect(onExpire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onExpire).toHaveBeenCalledWith('idle');
expect(timer.state().phase).toBe('expired');
timer.dispose();
});
it('starts the idle time again on activity before the warning', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
vi.advanceTimersByTime(20 * SECOND);
timer.activity();
// Without the reset the warning would have started at 30 s.
vi.advanceTimersByTime(29 * SECOND);
expect(timer.state().phase).toBe('active');
vi.advanceTimersByTime(1 * SECOND);
expect(timer.state().phase).toBe('warning');
expect(timer.state().deadline).toBe(START + 20 * SECOND + MINUTE);
vi.advanceTimersByTime(30 * SECOND);
expect(onExpire).toHaveBeenCalledTimes(1);
timer.dispose();
});
it('keeps the warning up on local activity until the user chooses to stay', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
vi.advanceTimersByTime(40 * SECOND);
expect(timer.state().phase).toBe('warning');
timer.activity();
expect(timer.state().phase).toBe('warning');
timer.stay();
expect(timer.state()).toMatchObject({ phase: 'active', deadline: START + 40 * SECOND + MINUTE });
vi.advanceTimersByTime(MINUTE - 1);
expect(onExpire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onExpire).toHaveBeenCalledWith('idle');
timer.dispose();
});
it('ends the warning when another tab reports activity', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
vi.advanceTimersByTime(45 * SECOND);
expect(timer.state().phase).toBe('warning');
timer.remoteActivity(Date.now());
expect(timer.state().phase).toBe('active');
vi.advanceTimersByTime(MINUTE - 1);
expect(onExpire).not.toHaveBeenCalled();
timer.dispose();
});
it('signs out after a sleep even when the wake-up arrives as activity first', () => {
const { timer, onExpire } = setup(idleOnly(15, 60));
// The machine slept: the clock jumped, no timer ran.
vi.setSystemTime(START + 2 * 60 * MINUTE);
timer.activity();
expect(onExpire).toHaveBeenCalledWith('idle');
expect(timer.state().phase).toBe('expired');
timer.remoteActivity(Date.now());
timer.stay();
expect(onExpire).toHaveBeenCalledTimes(1);
timer.dispose();
});
it('runs no clock without limits', () => {
const { timer, onExpire } = setup({ idle: null, max: null });
expect(timer.state()).toEqual({ phase: 'active', kind: null, deadline: null });
vi.advanceTimersByTime(365 * 24 * 60 * MINUTE);
expect(onExpire).not.toHaveBeenCalled();
timer.dispose();
});
it('follows new limits', () => {
const { timer, onExpire } = setup({ idle: null, max: null });
vi.advanceTimersByTime(10 * MINUTE);
timer.setLimits(idleOnly(5, 60));
// The last activity was ten minutes ago: already past five minutes.
expect(onExpire).toHaveBeenCalledWith('idle');
timer.dispose();
});
it('warns before the maximum session length and does not move it on activity', () => {
const at = START + 2 * 60 * MINUTE;
const { timer, onExpire } = setup({ idle: { minutes: 30, warnSeconds: 60 }, max: { at, hours: 2, warnSeconds: 60 } });
for (let i = 0; i < 119; i++) {
vi.advanceTimersByTime(MINUTE);
timer.activity();
}
expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max', deadline: at });
timer.stay();
expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max' });
vi.advanceTimersByTime(MINUTE);
expect(onExpire).toHaveBeenCalledWith('max');
timer.dispose();
});
it('counts a maximum far away without running its timer early', () => {
const at = START + 365 * 24 * 60 * MINUTE;
const { timer, onExpire } = setup({ idle: null, max: { at, hours: 365 * 24, warnSeconds: 60 } });
vi.advanceTimersByTime(30 * 24 * 60 * MINUTE);
expect(onExpire).not.toHaveBeenCalled();
expect(timer.state().phase).toBe('active');
timer.dispose();
});
});