Files
makelore/tests/unit/works-square-session.test.ts
brother7 86ece3a430 实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。

实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
2026-08-07 16:11:10 +08:00

406 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
consumeWorksSquareStartupRuntimeCleanupRequired,
flushWorksSquareSessionPersistence,
getValidWorksSquareAccessToken,
getWorksSquareSessionSnapshot,
initializeWorksSquareSession,
markWorksSquareSessionActive,
resetWorksSquareSessionForTests,
storeWorksSquareSession,
subscribeWorksSquareSession,
WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS,
type WorksSquareSessionInput,
type WorksSquareSessionPersistence,
} from '@electron/services/works-square-session';
const DAY_MS = 24 * 60 * 60 * 1000;
describe('works-square-session service', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
resetWorksSquareSessionForTests();
});
afterEach(() => {
vi.useRealTimers();
resetWorksSquareSessionForTests();
});
it('returns the cached access token while it is outside the refresh skew', async () => {
storeWorksSquareSession({
accessToken: 'access-token',
refreshToken: 'refresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 120_000,
});
await expect(getValidWorksSquareAccessToken()).resolves.toBe('access-token');
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 120_000,
canRefresh: true,
});
});
it('refreshes the access token when the cached session is near expiry', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
}), { status: 200 }),
);
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10_000,
});
await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBe('new-access-token');
expect(fetchImpl).toHaveBeenCalledWith(
'https://biz.nianxx.cn/auth/oauth2/token',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: `Basic ${Buffer.from('app:app').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
}),
body: expect.any(URLSearchParams),
}),
);
const body = fetchImpl.mock.calls[0][1].body as URLSearchParams;
expect(String(body)).toBe('grant_type=refresh_token&refresh_token=old-refresh-token');
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'new-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
canRefresh: true,
});
});
it('returns null and clears the cached access token when refresh fails with 401', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ msg: 'invalid refresh token' }), { status: 401 }),
);
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10_000,
});
await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBeNull();
expect(getWorksSquareSessionSnapshot()).toBeNull();
});
it('keeps the refresh token after a temporary OAuth failure', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ msg: 'temporarily unavailable' }), { status: 503 }),
);
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'refresh-token-for-retry',
expiresAt: Date.now() + 10_000,
});
await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBeNull();
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'old-access-token',
canRefresh: true,
});
});
it('keeps renewing when the user is active every day', async () => {
const startedAt = Date.now();
let refreshIndex = 0;
const fetchImpl = vi.fn().mockImplementation(async () => {
refreshIndex += 1;
return new Response(JSON.stringify({
access_token: `access-token-${refreshIndex}`,
refresh_token: `refresh-token-${refreshIndex}`,
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 });
});
storeWorksSquareSession({
accessToken: 'initial-access-token',
refreshToken: 'initial-refresh-token',
tokenType: 'Bearer',
expiresAt: startedAt + 3600_000,
lastActiveAt: startedAt,
});
for (let day = 1; day <= 8; day += 1) {
const nowMs = startedAt + day * DAY_MS;
vi.setSystemTime(nowMs);
await expect(markWorksSquareSessionActive(nowMs)).resolves.toMatchObject({
lastActiveAt: nowMs,
});
await expect(getValidWorksSquareAccessToken({
fetchImpl,
nowMs,
})).resolves.toBe(`access-token-${day}`);
expect(getWorksSquareSessionSnapshot()?.lastActiveAt).toBe(nowMs);
}
expect(fetchImpl).toHaveBeenCalledTimes(8);
expect(getWorksSquareSessionSnapshot()?.canRefresh).toBe(true);
});
it('requires authorization after seven days without user activity', async () => {
const lastActiveAt = Date.now();
const fetchImpl = vi.fn();
storeWorksSquareSession({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: lastActiveAt + 3600_000,
lastActiveAt,
});
const nowMs = lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS;
vi.setSystemTime(nowMs);
await expect(getValidWorksSquareAccessToken({
fetchImpl,
nowMs,
})).resolves.toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
expect(getWorksSquareSessionSnapshot()).toBeNull();
});
it('marks an idle session clear as terminal for Main runtime cleanup', async () => {
const listener = vi.fn();
subscribeWorksSquareSession(listener);
const lastActiveAt = Date.now();
storeWorksSquareSession({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: lastActiveAt + 3600_000,
lastActiveAt,
});
listener.mockClear();
await getValidWorksSquareAccessToken({
nowMs: lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS,
});
expect(listener).toHaveBeenCalledWith(null, 'terminal', expect.objectContaining({
accessToken: 'access-token',
}));
});
it('requests one startup runtime cleanup when a persisted session is idle', async () => {
const persistence: WorksSquareSessionPersistence = {
load: vi.fn().mockResolvedValue({
accessToken: 'stale-access-token',
refreshToken: 'stale-refresh-token',
expiresAt: Date.now() - 1,
lastActiveAt: Date.now() - WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS,
}),
save: vi.fn().mockResolvedValue(undefined),
};
await initializeWorksSquareSession({ persistence });
expect(consumeWorksSquareStartupRuntimeCleanupRequired()).toBe(true);
expect(consumeWorksSquareStartupRuntimeCleanupRequired()).toBe(false);
});
it('does not write an unnecessary clear when no persisted Main session exists', async () => {
const persistence: WorksSquareSessionPersistence = {
load: vi.fn().mockResolvedValue(null),
save: vi.fn().mockRejectedValue(new Error('credential store locked')),
};
await expect(initializeWorksSquareSession({ persistence })).resolves.toBeNull();
expect(persistence.save).not.toHaveBeenCalled();
});
it('does not count a background refresh as user activity', async () => {
const lastActiveAt = Date.now() - DAY_MS;
const fetchImpl = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 }),
);
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
expiresAt: Date.now() + 10_000,
lastActiveAt,
});
await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBe('new-access-token');
expect(getWorksSquareSessionSnapshot()).toMatchObject({
canRefresh: true,
lastActiveAt,
});
});
it('coalesces concurrent refreshes into one OAuth request', async () => {
let resolveRefresh!: (response: Response) => void;
const fetchImpl = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => {
resolveRefresh = resolve;
}));
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
expiresAt: Date.now() + 10_000,
});
const first = getValidWorksSquareAccessToken({ fetchImpl });
const second = getValidWorksSquareAccessToken({ fetchImpl });
expect(fetchImpl).toHaveBeenCalledOnce();
resolveRefresh(new Response(JSON.stringify({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 }));
await expect(Promise.all([first, second])).resolves.toEqual([
'new-access-token',
'new-access-token',
]);
expect(fetchImpl).toHaveBeenCalledOnce();
});
it('still allows refresh one millisecond before the seven-day boundary', async () => {
const lastActiveAt = Date.now();
const nowMs = lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS - 1;
const fetchImpl = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'renewed-access-token',
refresh_token: 'renewed-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 }),
);
storeWorksSquareSession({
accessToken: 'expired-access-token',
refreshToken: 'refresh-token',
expiresAt: lastActiveAt + 3600_000,
lastActiveAt,
});
vi.setSystemTime(nowMs);
await expect(getValidWorksSquareAccessToken({ fetchImpl, nowMs })).resolves.toBe(
'renewed-access-token',
);
expect(fetchImpl).toHaveBeenCalledOnce();
});
it('does not let a stale refresh failure clear a newer login', async () => {
let resolveOldRefresh!: (response: Response) => void;
const fetchImpl = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => {
resolveOldRefresh = resolve;
}));
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
expiresAt: Date.now() + 10_000,
});
const oldRefresh = getValidWorksSquareAccessToken({ fetchImpl });
storeWorksSquareSession({
accessToken: 'new-login-access-token',
refreshToken: 'new-login-refresh-token',
expiresAt: Date.now() + 3600_000,
});
resolveOldRefresh(new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 401 }));
await expect(oldRefresh).resolves.toBeNull();
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'new-login-access-token',
canRefresh: true,
});
});
it('does not return a newer login token to an older in-flight refresh', async () => {
let resolveOldRefresh!: (response: Response) => void;
const fetchImpl = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => {
resolveOldRefresh = resolve;
}));
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
expiresAt: Date.now() + 10_000,
});
const oldRefresh = getValidWorksSquareAccessToken({ fetchImpl });
storeWorksSquareSession({
accessToken: 'new-login-access-token',
refreshToken: 'new-login-refresh-token',
expiresAt: Date.now() + 3600_000,
});
resolveOldRefresh(new Response(JSON.stringify({
access_token: 'rotated-old-access-token',
refresh_token: 'rotated-old-refresh-token',
expires_in: 3600,
}), { status: 200 }));
await expect(oldRefresh).resolves.toBeNull();
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'new-login-access-token',
canRefresh: true,
});
});
it('persists a rotated refresh token before returning and restores it after restart', async () => {
let persisted: WorksSquareSessionInput | null = null;
const persistence: WorksSquareSessionPersistence = {
load: vi.fn(async () => persisted),
save: vi.fn(async (session) => {
persisted = session ? { ...session } : null;
}),
};
await initializeWorksSquareSession({ persistence });
storeWorksSquareSession({
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
expiresAt: Date.now() + 10_000,
});
await flushWorksSquareSessionPersistence();
const firstFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'rotated-access-token',
refresh_token: 'rotated-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 }),
);
await expect(getValidWorksSquareAccessToken({ fetchImpl: firstFetch })).resolves.toBe(
'rotated-access-token',
);
expect(persisted).toMatchObject({ refreshToken: 'rotated-refresh-token' });
resetWorksSquareSessionForTests();
await initializeWorksSquareSession({ persistence });
const secondFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'after-restart-access-token',
refresh_token: 'after-restart-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 }),
);
await getValidWorksSquareAccessToken({ fetchImpl: secondFetch, forceRefresh: true });
const body = secondFetch.mock.calls[0][1].body as URLSearchParams;
expect(String(body)).toContain('refresh_token=rotated-refresh-token');
});
});