session timeout

This commit is contained in:
2026-04-27 02:40:00 +09:30
parent c34551b868
commit 9e5427a262
10 changed files with 694 additions and 3 deletions
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { api, tokenManager } from '../src/lib/api';
// Mock localStorage for Node.js test environment
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
});
describe('Session API', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
global.fetch = fetchMock;
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('refreshSession', () => {
it('calls the correct endpoint and updates token expiry', async () => {
const mockResponse = {
success: true,
code: 200,
message: 'Session refreshed',
data: { expires_at: '2026-04-26T18:30:00Z' },
};
fetchMock.mockResolvedValueOnce({
json: () => Promise.resolve(mockResponse),
});
// Set a token first
tokenManager.setToken('test-token', '2026-04-26T17:00:00Z');
const result = await api.auth.refreshSession();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions/refresh'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
);
expect(result.expires_at).toBe('2026-04-26T18:30:00Z');
expect(tokenManager.getExpiry()).toBe('2026-04-26T18:30:00Z');
});
});
describe('listSessions', () => {
it('calls the correct endpoint', async () => {
const mockResponse = {
success: true,
code: 200,
message: 'Sessions listed',
data: {
sessions: [
{
id: 'sess-1',
created_at: '2026-04-26T10:00:00Z',
expires_at: '2026-04-26T18:00:00Z',
last_used_at: '2026-04-26T17:00:00Z',
ip_address: '127.0.0.1',
user_agent: 'Mozilla/5.0',
is_current: true,
},
],
},
};
fetchMock.mockResolvedValueOnce({
json: () => Promise.resolve(mockResponse),
});
tokenManager.setToken('test-token');
const result = await api.auth.listSessions();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions'),
expect.objectContaining({
credentials: 'include',
})
);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].id).toBe('sess-1');
});
});
describe('revokeSession', () => {
it('encodes session ID and calls the correct endpoint', async () => {
const mockResponse = {
success: true,
code: 200,
message: 'Session revoked',
data: {},
};
fetchMock.mockResolvedValueOnce({
json: () => Promise.resolve(mockResponse),
});
tokenManager.setToken('test-token');
await api.auth.revokeSession('sess-abc/123');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions/sess-abc%2F123'),
expect.objectContaining({
method: 'DELETE',
})
);
});
});
describe('tokenManager', () => {
it('updateExpiry sets the expiry in localStorage', () => {
tokenManager.setToken('test-token');
tokenManager.updateExpiry('2026-04-26T20:00:00Z');
expect(tokenManager.getExpiry()).toBe('2026-04-26T20:00:00Z');
});
it('getExpiry returns null when no expiry is set', () => {
localStorage.clear();
expect(tokenManager.getExpiry()).toBeNull();
});
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSessionTimeout } from '../src/hooks/useSessionTimeout';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
});
// Mock fetch
const fetchMock = vi.fn();
global.fetch = fetchMock;
describe('useSessionTimeout', () => {
const mockLogout = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
fetchMock.mockReset();
mockLogout.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it('should not show warning when user is active', () => {
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
expect(result.current.isWarningOpen).toBe(false);
});
it('should show warning after idle period approaches timeout', () => {
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
// Fast-forward 12 minutes (warning threshold is 3 min before 15 min timeout)
act(() => {
vi.advanceTimersByTime(12 * 60 * 1000);
});
// Fast-forward past the activity check interval (5 seconds)
act(() => {
vi.advanceTimersByTime(10 * 1000);
});
expect(result.current.isWarningOpen).toBe(true);
expect(result.current.countdownSeconds).toBeGreaterThan(0);
});
it('should call logout when session expires', async () => {
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
// Fast-forward past the 15-minute timeout
act(() => {
vi.advanceTimersByTime(16 * 60 * 1000);
});
// Wait for async logout
await act(async () => {
vi.advanceTimersByTime(10 * 1000);
});
expect(mockLogout).toHaveBeenCalled();
});
it('should extend session when extendSession is called', async () => {
fetchMock.mockResolvedValueOnce({
json: () =>
Promise.resolve({
success: true,
code: 200,
message: 'Session refreshed',
data: { expires_at: '2026-04-26T20:00:00Z' },
}),
});
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
// Trigger warning
act(() => {
vi.advanceTimersByTime(12 * 60 * 1000);
});
act(() => {
vi.advanceTimersByTime(10 * 1000);
});
expect(result.current.isWarningOpen).toBe(true);
// Extend session
await act(async () => {
await result.current.extendSession();
});
expect(result.current.isWarningOpen).toBe(false);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions/refresh'),
expect.objectContaining({ method: 'POST' })
);
});
it('should send heartbeat periodically', () => {
fetchMock.mockResolvedValue({
json: () =>
Promise.resolve({
success: true,
code: 200,
message: 'OK',
data: { user: { id: '1', email: 'test@example.com' } },
}),
});
renderHook(() => useSessionTimeout(true, mockLogout));
// Fast-forward past heartbeat interval (5 minutes)
act(() => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
});
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/users/me'),
expect.any(Object)
);
});
it('should logout on 401 heartbeat response', async () => {
fetchMock.mockRejectedValueOnce({ code: 401, message: 'Unauthorized' });
renderHook(() => useSessionTimeout(true, mockLogout));
// Fast-forward past heartbeat interval
await act(async () => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
});
// Wait for the async catch block
await act(async () => {
await Promise.resolve();
});
expect(mockLogout).toHaveBeenCalled();
});
});