Skip to content

Commit 821553d

Browse files
committed
Merge branch 'release/pr-325' into release/integration-1.2.2
# Conflicts: # lib/refresh-guardian.ts
2 parents 7410d4c + c97350c commit 821553d

2 files changed

Lines changed: 270 additions & 1 deletion

File tree

lib/refresh-guardian.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,80 @@ export interface RefreshGuardianStats {
2626

2727
const DEFAULT_INTERVAL_MS = 60_000;
2828

29+
function findMatchingLiveAccountIndexes(
30+
liveAccounts: ManagedAccount[],
31+
predicate: (candidate: ManagedAccount) => boolean,
32+
): number[] {
33+
const matches: number[] = [];
34+
for (const [index, candidate] of liveAccounts.entries()) {
35+
if (predicate(candidate)) {
36+
matches.push(index);
37+
}
38+
}
39+
return matches;
40+
}
41+
42+
function resolveLiveAccountIndex(
43+
liveAccounts: ManagedAccount[],
44+
sourceAccount: ManagedAccount,
45+
): number {
46+
if (sourceAccount.accountId) {
47+
const accountIdMatches = findMatchingLiveAccountIndexes(
48+
liveAccounts,
49+
(candidate) => candidate.accountId === sourceAccount.accountId,
50+
);
51+
const resolvedIndex = accountIdMatches[0];
52+
if (resolvedIndex !== undefined) {
53+
log.debug("Resolved refreshed account by accountId", {
54+
sourceIndex: sourceAccount.index,
55+
resolvedIndex,
56+
matchCount: accountIdMatches.length,
57+
});
58+
if (accountIdMatches.length > 1) {
59+
log.warn("Duplicate live accountId matches during refresh reconciliation", {
60+
sourceIndex: sourceAccount.index,
61+
resolvedIndex,
62+
matchCount: accountIdMatches.length,
63+
});
64+
}
65+
return resolvedIndex;
66+
}
67+
}
68+
69+
const sourceEmail = sanitizeEmail(sourceAccount.email);
70+
if (sourceEmail) {
71+
const emailMatches = findMatchingLiveAccountIndexes(
72+
liveAccounts,
73+
(candidate) => sanitizeEmail(candidate.email) === sourceEmail,
74+
);
75+
const resolvedIndex = emailMatches[0];
76+
if (resolvedIndex !== undefined) {
77+
log.debug("Resolved refreshed account by email", {
78+
sourceIndex: sourceAccount.index,
79+
resolvedIndex,
80+
matchCount: emailMatches.length,
81+
});
82+
if (emailMatches.length > 1) {
83+
log.warn("Duplicate live email matches during refresh reconciliation", {
84+
sourceIndex: sourceAccount.index,
85+
resolvedIndex,
86+
matchCount: emailMatches.length,
87+
});
88+
}
89+
return resolvedIndex;
90+
}
91+
}
92+
93+
const byToken = liveAccounts.findIndex(
94+
(candidate) => candidate.refreshToken === sourceAccount.refreshToken,
95+
);
96+
log.debug("Resolved refreshed account by refresh token fallback", {
97+
sourceIndex: sourceAccount.index,
98+
resolvedIndex: byToken,
99+
});
100+
return byToken;
101+
}
102+
29103
export class RefreshGuardian {
30104
private readonly getAccountManager: () => AccountManager | null;
31105
private readonly intervalMs: number;

test/refresh-guardian.test.ts

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ vi.mock("../lib/proactive-refresh.js", () => ({
3434
function createManagedAccount(index: number): ManagedAccount {
3535
return {
3636
index,
37+
accountId: `acct-${index}`,
38+
email: `user${index}@example.com`,
3739
refreshToken: `refresh-${index}`,
3840
addedAt: Date.now() - 10_000,
3941
lastUsed: Date.now() - 5_000,
@@ -331,7 +333,7 @@ describe("refresh-guardian", () => {
331333
expect(tickSpy).toHaveBeenCalledTimes(1);
332334
});
333335

334-
it("resolves refreshed account using stable refresh token when indices shift", async () => {
336+
it("resolves refreshed account by accountId when indices shift", async () => {
335337
const originalA = createManagedAccount(0);
336338
const originalB = createManagedAccount(1);
337339
const liveB = { ...originalB, index: 0 };
@@ -399,6 +401,196 @@ describe("refresh-guardian", () => {
399401
);
400402
});
401403

404+
it("resolves refreshed account by accountId after refresh token rotation", async () => {
405+
const originalA = createManagedAccount(0);
406+
const originalB = createManagedAccount(1);
407+
const liveA = {
408+
...originalA,
409+
index: 1,
410+
refreshToken: "refresh-0-rotated",
411+
};
412+
const liveB = { ...originalB, index: 0 };
413+
const snapshots = [
414+
[originalA, originalB],
415+
[liveB, liveA],
416+
];
417+
let readCount = 0;
418+
const manager = {
419+
getAccountsSnapshot: vi.fn(
420+
() => snapshots[Math.min(readCount++, snapshots.length - 1)],
421+
),
422+
getAccountByIndex: vi.fn(
423+
(index: number) =>
424+
[liveB, liveA].find((account) => account.index === index) ?? null,
425+
),
426+
clearAuthFailures: vi.fn(),
427+
markAccountCoolingDown: vi.fn(),
428+
setAccountEnabled: vi.fn(),
429+
saveToDiskDebounced: vi.fn(),
430+
} as unknown as AccountManager;
431+
const { RefreshGuardian } = await import("../lib/refresh-guardian.js");
432+
const guardian = new RefreshGuardian(() => manager, {
433+
bufferMs: 60_000,
434+
intervalMs: 5_000,
435+
});
436+
437+
refreshExpiringAccountsMock.mockResolvedValue(
438+
new Map([
439+
[
440+
0,
441+
{
442+
refreshed: true,
443+
reason: "success",
444+
tokenResult: {
445+
type: "success",
446+
access: "access-account-id",
447+
refresh: "refresh-account-id",
448+
expires: Date.now() + 3_600_000,
449+
},
450+
},
451+
],
452+
]),
453+
);
454+
455+
await guardian.tick();
456+
457+
expect(applyRefreshResultMock).toHaveBeenCalledWith(
458+
liveA,
459+
expect.objectContaining({ type: "success" }),
460+
);
461+
expect(
462+
manager.clearAuthFailures as ReturnType<typeof vi.fn>,
463+
).toHaveBeenCalledWith(liveA);
464+
});
465+
466+
it("falls back to refreshToken when accountId is unavailable and email is invalid", async () => {
467+
const originalA = {
468+
...createManagedAccount(0),
469+
accountId: undefined,
470+
email: "invalid-no-at",
471+
};
472+
const originalB = createManagedAccount(1);
473+
const liveA = {
474+
...originalA,
475+
index: 1,
476+
};
477+
const liveB = { ...originalB, index: 0 };
478+
const snapshots = [
479+
[originalA, originalB],
480+
[liveB, liveA],
481+
];
482+
let readCount = 0;
483+
const manager = {
484+
getAccountsSnapshot: vi.fn(
485+
() => snapshots[Math.min(readCount++, snapshots.length - 1)],
486+
),
487+
getAccountByIndex: vi.fn(
488+
(index: number) =>
489+
[liveB, liveA].find((account) => account.index === index) ?? null,
490+
),
491+
clearAuthFailures: vi.fn(),
492+
markAccountCoolingDown: vi.fn(),
493+
setAccountEnabled: vi.fn(),
494+
saveToDiskDebounced: vi.fn(),
495+
} as unknown as AccountManager;
496+
const { RefreshGuardian } = await import("../lib/refresh-guardian.js");
497+
const guardian = new RefreshGuardian(() => manager, {
498+
bufferMs: 60_000,
499+
intervalMs: 5_000,
500+
});
501+
502+
refreshExpiringAccountsMock.mockResolvedValue(
503+
new Map([
504+
[
505+
0,
506+
{
507+
refreshed: true,
508+
reason: "success",
509+
tokenResult: {
510+
type: "success",
511+
access: "access-token",
512+
refresh: "refresh-token",
513+
expires: Date.now() + 3_600_000,
514+
},
515+
},
516+
],
517+
]),
518+
);
519+
520+
await guardian.tick();
521+
522+
expect(applyRefreshResultMock).toHaveBeenCalledWith(
523+
liveA,
524+
expect.objectContaining({ type: "success" }),
525+
);
526+
expect(
527+
manager.clearAuthFailures as ReturnType<typeof vi.fn>,
528+
).toHaveBeenCalledWith(liveA);
529+
});
530+
531+
it("treats empty string accountId the same as undefined by using normalized email", async () => {
532+
const originalA = { ...createManagedAccount(0), accountId: "", email: " User0@Example.com " };
533+
const originalB = createManagedAccount(1);
534+
const liveA = {
535+
...originalA,
536+
index: 1,
537+
refreshToken: "refresh-0-rotated",
538+
email: "user0@example.com",
539+
};
540+
const liveB = { ...originalB, index: 0 };
541+
const snapshots = [
542+
[originalA, originalB],
543+
[liveB, liveA],
544+
];
545+
let readCount = 0;
546+
const manager = {
547+
getAccountsSnapshot: vi.fn(
548+
() => snapshots[Math.min(readCount++, snapshots.length - 1)],
549+
),
550+
getAccountByIndex: vi.fn(
551+
(index: number) =>
552+
[liveB, liveA].find((account) => account.index === index) ?? null,
553+
),
554+
clearAuthFailures: vi.fn(),
555+
markAccountCoolingDown: vi.fn(),
556+
setAccountEnabled: vi.fn(),
557+
saveToDiskDebounced: vi.fn(),
558+
} as unknown as AccountManager;
559+
const { RefreshGuardian } = await import("../lib/refresh-guardian.js");
560+
const guardian = new RefreshGuardian(() => manager, {
561+
bufferMs: 60_000,
562+
intervalMs: 5_000,
563+
});
564+
565+
refreshExpiringAccountsMock.mockResolvedValue(
566+
new Map([
567+
[
568+
0,
569+
{
570+
refreshed: true,
571+
reason: "success",
572+
tokenResult: {
573+
type: "success",
574+
access: "access-email",
575+
refresh: "refresh-email",
576+
expires: Date.now() + 3_600_000,
577+
},
578+
},
579+
],
580+
]),
581+
);
582+
583+
await guardian.tick();
584+
585+
expect(applyRefreshResultMock).toHaveBeenCalledWith(
586+
liveA,
587+
expect.objectContaining({ type: "success" }),
588+
);
589+
expect(
590+
manager.clearAuthFailures as ReturnType<typeof vi.fn>,
591+
).toHaveBeenCalledWith(liveA);
592+
});
593+
402594
it("classifies failure reasons and handles no-op branches", async () => {
403595
const accountA = createManagedAccount(0);
404596
const accountB = createManagedAccount(1);
@@ -649,6 +841,9 @@ describe("refresh-guardian", () => {
649841
expect(
650842
manager.saveToDiskDebounced as ReturnType<typeof vi.fn>,
651843
).toHaveBeenCalledTimes(1);
844+
expect(
845+
manager.getAccountsSnapshot as ReturnType<typeof vi.fn>,
846+
).toHaveBeenCalledTimes(2);
652847

653848
const stats = guardian.getStats();
654849
expect(stats.runs).toBe(1);

0 commit comments

Comments
 (0)