Skip to content

Commit f93ebd3

Browse files
committed
test: cover ensureFreshAccessToken refresh, cooldown, and dedup paths
Direct coverage for the phase-2-extracted rotation token-refresh helper, driven through a REAL AccountManager (cooldown bookkeeping, commitRefreshedAuth rollback machinery, and the live in-memory pool all run; only the refresh queue and the storage transaction seam are mocked): - a fresh token short-circuits without touching the refresh queue - a stale token refreshes and the rotated credentials land in both the result and the in-memory pool - concurrent callers share one in-flight commit (gated transaction so the dedup window is actually open) and receive the same token - a non-retryable 401 applies the 30s auth-failure cooldown and reports retryable=false, invalidated=false; a network error reports retryable=true - an explicit revocation message applies the long invalidation cooldown and signals invalidated=true (issue #495) - the monotonic guard never lets a later 30s generic failure truncate a concurrent 5-minute invalidation cooldown, and applyMonotonicAuthCooldown only ever extends deadlines - a failed commit cools the account down and stays retryable https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
1 parent 6ede089 commit f93ebd3

1 file changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { AccountManager } from "../lib/accounts.js";
3+
import type { AccountStorageV3 } from "../lib/storage.js";
4+
5+
const { queuedRefreshMock, saveAccountsMock, withAccountStorageTransactionMock } =
6+
vi.hoisted(() => ({
7+
queuedRefreshMock: vi.fn(),
8+
saveAccountsMock: vi.fn(),
9+
withAccountStorageTransactionMock: vi.fn(),
10+
}));
11+
12+
vi.mock("../lib/refresh-queue.js", async (importOriginal) => {
13+
const actual = await importOriginal<typeof import("../lib/refresh-queue.js")>();
14+
return { ...actual, queuedRefresh: queuedRefreshMock };
15+
});
16+
17+
vi.mock("../lib/storage.js", async (importOriginal) => {
18+
const actual = await importOriginal<typeof import("../lib/storage.js")>();
19+
return {
20+
...actual,
21+
saveAccounts: saveAccountsMock,
22+
withAccountStorageTransaction: withAccountStorageTransactionMock,
23+
};
24+
});
25+
26+
vi.mock("../lib/codex-cli/writer.js", () => ({
27+
setCodexCliActiveSelection: vi.fn().mockResolvedValue(true),
28+
}));
29+
30+
const {
31+
applyMonotonicAuthCooldown,
32+
DEFAULT_AUTH_FAILURE_COOLDOWN_MS,
33+
ensureFreshAccessToken,
34+
} = await import("../lib/runtime/rotation-token-refresh.js");
35+
36+
const NOW = Date.now();
37+
const FAMILY = "gpt-5-codex" as const;
38+
const SKEW_MS = 30_000;
39+
const INVALIDATION_COOLDOWN_MS = 300_000;
40+
41+
function storageWith(expiresAt: number): AccountStorageV3 {
42+
return {
43+
version: 3,
44+
activeIndex: 0,
45+
activeIndexByFamily: { [FAMILY]: 0 },
46+
accounts: [
47+
{
48+
email: "account-1@example.com",
49+
accountId: "acc_1",
50+
refreshToken: "refresh-1",
51+
accessToken: "access-1",
52+
expiresAt,
53+
addedAt: NOW - 60_000,
54+
lastUsed: NOW - 60_000,
55+
enabled: true,
56+
},
57+
],
58+
};
59+
}
60+
61+
const FRESH_EXPIRES = NOW + 3_600_000;
62+
// Inside the refresh skew window: the proxy must refresh before using it.
63+
const STALE_EXPIRES = NOW + 10_000;
64+
65+
const openManagers: AccountManager[] = [];
66+
67+
function managerWith(expiresAt: number): AccountManager {
68+
const accountManager = new AccountManager(undefined, storageWith(expiresAt));
69+
openManagers.push(accountManager);
70+
return accountManager;
71+
}
72+
73+
function refreshParams(accountManager: AccountManager) {
74+
const account = accountManager.getAccountByIndex(0);
75+
if (!account) throw new Error("fixture account missing");
76+
return {
77+
accountManager,
78+
account,
79+
family: FAMILY,
80+
model: null,
81+
now: NOW,
82+
tokenRefreshSkewMs: SKEW_MS,
83+
tokenInvalidationCooldownMs: INVALIDATION_COOLDOWN_MS,
84+
};
85+
}
86+
87+
beforeEach(() => {
88+
vi.clearAllMocks();
89+
AccountManager.resetVolatileRuntimeState();
90+
saveAccountsMock.mockResolvedValue(undefined);
91+
withAccountStorageTransactionMock.mockImplementation(async (handler) =>
92+
handler(null, async () => undefined),
93+
);
94+
});
95+
96+
afterEach(async () => {
97+
for (const accountManager of openManagers.splice(0, openManagers.length)) {
98+
await accountManager.flushPendingSave();
99+
}
100+
});
101+
102+
describe("ensureFreshAccessToken", () => {
103+
it("uses a fresh token as-is without touching the refresh queue", async () => {
104+
const accountManager = managerWith(FRESH_EXPIRES);
105+
106+
const result = await ensureFreshAccessToken(refreshParams(accountManager));
107+
108+
expect(result).toMatchObject({ ok: true, accessToken: "access-1" });
109+
expect(queuedRefreshMock).not.toHaveBeenCalled();
110+
});
111+
112+
it("refreshes a stale token and commits the rotated credentials", async () => {
113+
const accountManager = managerWith(STALE_EXPIRES);
114+
queuedRefreshMock.mockResolvedValue({
115+
type: "success",
116+
access: "access-new",
117+
refresh: "refresh-new",
118+
expires: NOW + 7_200_000,
119+
});
120+
121+
const result = await ensureFreshAccessToken(refreshParams(accountManager));
122+
123+
expect(queuedRefreshMock).toHaveBeenCalledExactlyOnceWith("refresh-1");
124+
expect(result.ok).toBe(true);
125+
if (result.ok) {
126+
expect(result.accessToken).toBe("access-new");
127+
expect(result.account.access).toBe("access-new");
128+
}
129+
// The in-memory pool now carries the rotated credentials.
130+
expect(accountManager.getAccountByIndex(0)).toMatchObject({
131+
access: "access-new",
132+
refreshToken: "refresh-new",
133+
});
134+
});
135+
136+
it("deduplicates concurrent commits for the same refreshed account", async () => {
137+
const accountManager = managerWith(STALE_EXPIRES);
138+
const commit = vi.spyOn(accountManager, "commitRefreshedAuth");
139+
queuedRefreshMock.mockResolvedValue({
140+
type: "success",
141+
access: "access-new",
142+
refresh: "refresh-new",
143+
expires: NOW + 7_200_000,
144+
});
145+
// Hold the first commit open so the second caller arrives while it is
146+
// still in flight — that is the window the dedup queue exists for.
147+
let releaseCommit!: () => void;
148+
const commitGate = new Promise<void>((resolve) => {
149+
releaseCommit = resolve;
150+
});
151+
withAccountStorageTransactionMock.mockImplementation(async (handler) => {
152+
await commitGate;
153+
return handler(null, async () => undefined);
154+
});
155+
const params = refreshParams(accountManager);
156+
157+
const firstPending = ensureFreshAccessToken(params);
158+
const secondPending = ensureFreshAccessToken({ ...params });
159+
await new Promise<void>((resolve) => setImmediate(resolve));
160+
releaseCommit();
161+
const [first, second] = await Promise.all([firstPending, secondPending]);
162+
163+
// Both callers share the single in-flight commit and the same token.
164+
expect(commit).toHaveBeenCalledTimes(1);
165+
expect(first).toMatchObject({ ok: true, accessToken: "access-new" });
166+
expect(second).toMatchObject({ ok: true, accessToken: "access-new" });
167+
});
168+
169+
it("applies the short cooldown on a non-retryable auth failure", async () => {
170+
const accountManager = managerWith(STALE_EXPIRES);
171+
queuedRefreshMock.mockResolvedValue({
172+
type: "failed",
173+
reason: "http_error",
174+
statusCode: 401,
175+
message: "token expired",
176+
});
177+
178+
const result = await ensureFreshAccessToken(refreshParams(accountManager));
179+
180+
expect(result).toMatchObject({
181+
ok: false,
182+
retryable: false,
183+
invalidated: false,
184+
});
185+
const coolingDownUntil =
186+
accountManager.getAccountByIndex(0)?.coolingDownUntil ?? 0;
187+
expect(coolingDownUntil).toBeGreaterThan(NOW);
188+
expect(coolingDownUntil).toBeLessThanOrEqual(
189+
Date.now() + DEFAULT_AUTH_FAILURE_COOLDOWN_MS,
190+
);
191+
});
192+
193+
it("marks transient refresh failures retryable", async () => {
194+
const accountManager = managerWith(STALE_EXPIRES);
195+
queuedRefreshMock.mockResolvedValue({
196+
type: "failed",
197+
reason: "network_error",
198+
message: "fetch failed",
199+
});
200+
201+
const result = await ensureFreshAccessToken(refreshParams(accountManager));
202+
203+
expect(result).toMatchObject({ ok: false, retryable: true });
204+
});
205+
206+
it("applies the long cooldown and signals invalidation on a revoked token", async () => {
207+
const accountManager = managerWith(STALE_EXPIRES);
208+
queuedRefreshMock.mockResolvedValue({
209+
type: "failed",
210+
reason: "http_error",
211+
statusCode: 401,
212+
message: "OAuth token has been invalidated",
213+
});
214+
215+
const result = await ensureFreshAccessToken(refreshParams(accountManager));
216+
217+
expect(result).toMatchObject({ ok: false, invalidated: true });
218+
const coolingDownUntil =
219+
accountManager.getAccountByIndex(0)?.coolingDownUntil ?? 0;
220+
// The invalidation cooldown is the long one, far beyond the 30s default.
221+
expect(coolingDownUntil).toBeGreaterThan(
222+
NOW + INVALIDATION_COOLDOWN_MS - 10_000,
223+
);
224+
});
225+
226+
it("never lets a later generic failure truncate an invalidation cooldown", async () => {
227+
const accountManager = managerWith(STALE_EXPIRES);
228+
const account = accountManager.getAccountByIndex(0);
229+
if (!account) throw new Error("fixture account missing");
230+
accountManager.markAccountCoolingDown(
231+
account,
232+
INVALIDATION_COOLDOWN_MS,
233+
"auth-failure",
234+
);
235+
const longDeadline =
236+
accountManager.getAccountByIndex(0)?.coolingDownUntil ?? 0;
237+
queuedRefreshMock.mockResolvedValue({
238+
type: "failed",
239+
reason: "http_error",
240+
statusCode: 401,
241+
message: "token expired",
242+
});
243+
244+
await ensureFreshAccessToken(refreshParams(accountManager));
245+
246+
// Monotonic guard: the 30s generic cooldown must not shorten the
247+
// 5-minute invalidation cooldown set by a concurrent request.
248+
expect(accountManager.getAccountByIndex(0)?.coolingDownUntil).toBe(
249+
longDeadline,
250+
);
251+
});
252+
253+
it("cools down and stays retryable when the commit itself fails", async () => {
254+
const accountManager = managerWith(STALE_EXPIRES);
255+
queuedRefreshMock.mockResolvedValue({
256+
type: "success",
257+
access: "access-new",
258+
refresh: "refresh-new",
259+
expires: NOW + 7_200_000,
260+
});
261+
// Once: the post-test debounced-save flush must still see the working
262+
// transaction implementation from beforeEach.
263+
withAccountStorageTransactionMock.mockRejectedValueOnce(
264+
Object.assign(new Error("locked"), { code: "EBUSY" }),
265+
);
266+
267+
const result = await ensureFreshAccessToken(refreshParams(accountManager));
268+
269+
expect(result).toMatchObject({ ok: false, retryable: true });
270+
expect(
271+
accountManager.getAccountByIndex(0)?.coolingDownUntil ?? 0,
272+
).toBeGreaterThan(NOW);
273+
});
274+
});
275+
276+
describe("applyMonotonicAuthCooldown", () => {
277+
it("extends an absent cooldown but never shortens an existing one", () => {
278+
const accountManager = managerWith(FRESH_EXPIRES);
279+
const account = accountManager.getAccountByIndex(0);
280+
if (!account) throw new Error("fixture account missing");
281+
282+
applyMonotonicAuthCooldown(accountManager, account, 60_000);
283+
const firstDeadline =
284+
accountManager.getAccountByIndex(0)?.coolingDownUntil ?? 0;
285+
expect(firstDeadline).toBeGreaterThan(NOW);
286+
287+
applyMonotonicAuthCooldown(accountManager, account, 1_000);
288+
expect(accountManager.getAccountByIndex(0)?.coolingDownUntil).toBe(
289+
firstDeadline,
290+
);
291+
292+
applyMonotonicAuthCooldown(accountManager, account, 600_000);
293+
expect(
294+
accountManager.getAccountByIndex(0)?.coolingDownUntil ?? 0,
295+
).toBeGreaterThan(firstDeadline);
296+
});
297+
});

0 commit comments

Comments
 (0)