529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
consumeWorksSquareStartupRuntimeCleanupRequired,
|
|
clearWorksSquareSession,
|
|
flushWorksSquareSessionPersistence,
|
|
getValidWorksSquareAccessToken,
|
|
getWorksSquareAccountBinding,
|
|
getWorksSquareSessionSnapshot,
|
|
initializeWorksSquareSession,
|
|
markWorksSquareSessionActive,
|
|
resetWorksSquareSessionForTests,
|
|
storeWorksSquareSession,
|
|
storeWorksSquareSessionFromTokenPayload,
|
|
subscribeWorksSquareSession,
|
|
WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS,
|
|
type WorksSquareSessionInput,
|
|
type WorksSquareSessionPersistence,
|
|
} from '@electron/services/works-square-session';
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function jwt(claims: Record<string, unknown>): string {
|
|
return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`;
|
|
}
|
|
|
|
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://square.nianxx.cn/api/auth/refresh',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ 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('expires a stuck shared refresh and permits the next refresh attempt', async () => {
|
|
let requestSignal: AbortSignal | null = null;
|
|
const fetchImpl = vi.fn<typeof fetch>((_input, init) => {
|
|
requestSignal = init?.signal ?? null;
|
|
return new Promise<Response>(() => undefined);
|
|
});
|
|
storeWorksSquareSession({
|
|
accessToken: 'old-access-token',
|
|
refreshToken: 'old-refresh-token',
|
|
expiresAt: Date.now() + 10_000,
|
|
});
|
|
|
|
const first = getValidWorksSquareAccessToken({ fetchImpl, requestTimeoutMs: 25 });
|
|
const second = getValidWorksSquareAccessToken({ fetchImpl, requestTimeoutMs: 25 });
|
|
const outcomes = Promise.allSettled([first, second]);
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
|
|
await expect(Promise.race([
|
|
outcomes,
|
|
Promise.resolve('pending'),
|
|
])).resolves.toEqual([
|
|
expect.objectContaining({
|
|
status: 'rejected',
|
|
reason: expect.objectContaining({ name: 'RequestDeadlineExceededError' }),
|
|
}),
|
|
expect.objectContaining({
|
|
status: 'rejected',
|
|
reason: expect.objectContaining({ name: 'RequestDeadlineExceededError' }),
|
|
}),
|
|
]);
|
|
expect(requestSignal?.aborted).toBe(true);
|
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
|
|
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
access_token: 'new-access-token',
|
|
refresh_token: 'new-refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}), { status: 200 }));
|
|
|
|
await expect(getValidWorksSquareAccessToken({
|
|
fetchImpl,
|
|
requestTimeoutMs: 25,
|
|
})).resolves.toBe('new-access-token');
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(secondFetch.mock.calls[0][1].body).toBe(JSON.stringify({
|
|
refresh_token: 'rotated-refresh-token',
|
|
}));
|
|
});
|
|
|
|
it('uses user_id ahead of username for a stable opaque account partition', () => {
|
|
storeWorksSquareSessionFromTokenPayload({
|
|
access_token: 'access-a',
|
|
user_id: 'raw-user-123',
|
|
username: 'first@example.com',
|
|
expires_in: 3600,
|
|
});
|
|
const first = getWorksSquareAccountBinding();
|
|
|
|
storeWorksSquareSessionFromTokenPayload({
|
|
access_token: 'access-b',
|
|
user_id: 'raw-user-123',
|
|
username: 'renamed@example.com',
|
|
expires_in: 3600,
|
|
});
|
|
const second = getWorksSquareAccountBinding();
|
|
|
|
expect(first?.accountKey).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(second?.accountKey).toBe(first?.accountKey);
|
|
expect(second?.accountKey).not.toContain('raw-user-123');
|
|
expect(second?.epoch).toBe(first?.epoch);
|
|
});
|
|
|
|
it('retains the account partition across refresh, clears it on logout, and changes it on account switch', async () => {
|
|
storeWorksSquareSessionFromTokenPayload({
|
|
access_token: 'old-a',
|
|
refresh_token: 'refresh-a',
|
|
user_id: 'account-a',
|
|
expires_in: 1,
|
|
});
|
|
const accountA = getWorksSquareAccountBinding();
|
|
const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
access_token: 'refreshed-a',
|
|
refresh_token: 'refresh-a-2',
|
|
user_id: 'unexpected-account-b',
|
|
expires_in: 3600,
|
|
}), { status: 200 }));
|
|
|
|
await getValidWorksSquareAccessToken({ fetchImpl, forceRefresh: true });
|
|
expect(getWorksSquareAccountBinding()).toEqual(accountA);
|
|
|
|
clearWorksSquareSession();
|
|
expect(getWorksSquareAccountBinding()).toBeNull();
|
|
storeWorksSquareSessionFromTokenPayload({
|
|
access_token: 'access-b',
|
|
user_id: 'account-b',
|
|
expires_in: 3600,
|
|
});
|
|
expect(getWorksSquareAccountBinding()?.accountKey).not.toBe(accountA?.accountKey);
|
|
expect(getWorksSquareAccountBinding()?.epoch).toBeGreaterThan(accountA?.epoch ?? 0);
|
|
});
|
|
|
|
it('migrates a legacy persisted Renderer session from safe JWT identity claims', async () => {
|
|
const persistence: WorksSquareSessionPersistence = {
|
|
load: vi.fn().mockResolvedValue({
|
|
accessToken: jwt({ user_id: 'legacy-user', username: 'legacy@example.com' }),
|
|
refreshToken: 'legacy-refresh',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now(),
|
|
}),
|
|
save: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
await initializeWorksSquareSession({ persistence });
|
|
|
|
const binding = getWorksSquareAccountBinding();
|
|
expect(binding?.accountKey).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(binding?.accountKey).not.toContain('legacy-user');
|
|
expect(persistence.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
accountPartitionKey: binding?.accountKey,
|
|
}));
|
|
});
|
|
});
|