Skip to content

Commit c97350c

Browse files
committed
fix: harden guardian identity reconciliation
1 parent 27a0d1e commit c97350c

2 files changed

Lines changed: 127 additions & 9 deletions

File tree

lib/refresh-guardian.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,78 @@ export interface RefreshGuardianStats {
2525

2626
const DEFAULT_INTERVAL_MS = 60_000;
2727

28+
function findMatchingLiveAccountIndexes(
29+
liveAccounts: ManagedAccount[],
30+
predicate: (candidate: ManagedAccount) => boolean,
31+
): number[] {
32+
const matches: number[] = [];
33+
for (const [index, candidate] of liveAccounts.entries()) {
34+
if (predicate(candidate)) {
35+
matches.push(index);
36+
}
37+
}
38+
return matches;
39+
}
40+
2841
function resolveLiveAccountIndex(
2942
liveAccounts: ManagedAccount[],
3043
sourceAccount: ManagedAccount,
3144
): number {
3245
if (sourceAccount.accountId) {
33-
const byAccountId = liveAccounts.findIndex(
46+
const accountIdMatches = findMatchingLiveAccountIndexes(
47+
liveAccounts,
3448
(candidate) => candidate.accountId === sourceAccount.accountId,
3549
);
36-
if (byAccountId >= 0) return byAccountId;
50+
const resolvedIndex = accountIdMatches[0];
51+
if (resolvedIndex !== undefined) {
52+
log.debug("Resolved refreshed account by accountId", {
53+
sourceIndex: sourceAccount.index,
54+
resolvedIndex,
55+
matchCount: accountIdMatches.length,
56+
});
57+
if (accountIdMatches.length > 1) {
58+
log.warn("Duplicate live accountId matches during refresh reconciliation", {
59+
sourceIndex: sourceAccount.index,
60+
resolvedIndex,
61+
matchCount: accountIdMatches.length,
62+
});
63+
}
64+
return resolvedIndex;
65+
}
3766
}
3867

3968
const sourceEmail = sanitizeEmail(sourceAccount.email);
4069
if (sourceEmail) {
41-
const byEmail = liveAccounts.findIndex(
70+
const emailMatches = findMatchingLiveAccountIndexes(
71+
liveAccounts,
4272
(candidate) => sanitizeEmail(candidate.email) === sourceEmail,
4373
);
44-
if (byEmail >= 0) return byEmail;
74+
const resolvedIndex = emailMatches[0];
75+
if (resolvedIndex !== undefined) {
76+
log.debug("Resolved refreshed account by email", {
77+
sourceIndex: sourceAccount.index,
78+
resolvedIndex,
79+
matchCount: emailMatches.length,
80+
});
81+
if (emailMatches.length > 1) {
82+
log.warn("Duplicate live email matches during refresh reconciliation", {
83+
sourceIndex: sourceAccount.index,
84+
resolvedIndex,
85+
matchCount: emailMatches.length,
86+
});
87+
}
88+
return resolvedIndex;
89+
}
4590
}
4691

47-
return liveAccounts.findIndex(
92+
const byToken = liveAccounts.findIndex(
4893
(candidate) => candidate.refreshToken === sourceAccount.refreshToken,
4994
);
95+
log.debug("Resolved refreshed account by refresh token fallback", {
96+
sourceIndex: sourceAccount.index,
97+
resolvedIndex: byToken,
98+
});
99+
return byToken;
50100
}
51101

52102
export class RefreshGuardian {
@@ -141,11 +191,11 @@ export class RefreshGuardian {
141191
for (const candidate of snapshot) {
142192
snapshotByIndex.set(candidate.index, candidate);
143193
}
194+
const liveAccounts = manager.getAccountsSnapshot();
144195

145196
for (const [accountIndex, result] of refreshResults.entries()) {
146197
const sourceAccount = snapshotByIndex.get(accountIndex);
147198
if (!sourceAccount) continue;
148-
const liveAccounts = manager.getAccountsSnapshot();
149199
const resolvedIndex = resolveLiveAccountIndex(liveAccounts, sourceAccount);
150200
const account = resolvedIndex >= 0 ? manager.getAccountByIndex(resolvedIndex) : null;
151201
if (!account) continue;

test/refresh-guardian.test.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ describe("refresh-guardian", () => {
216216
expect(tickSpy).toHaveBeenCalledTimes(1);
217217
});
218218

219-
it("resolves refreshed account using stable refresh token when indices shift", async () => {
219+
it("resolves refreshed account by accountId when indices shift", async () => {
220220
const originalA = createManagedAccount(0);
221221
const originalB = createManagedAccount(1);
222222
const liveB = { ...originalB, index: 0 };
@@ -336,8 +336,73 @@ describe("refresh-guardian", () => {
336336
).toHaveBeenCalledWith(liveA);
337337
});
338338

339-
it("falls back to normalized email when accountId is unavailable", async () => {
340-
const originalA = { ...createManagedAccount(0), accountId: undefined, email: " User0@Example.com " };
339+
it("falls back to refreshToken when accountId is unavailable and email is invalid", async () => {
340+
const originalA = {
341+
...createManagedAccount(0),
342+
accountId: undefined,
343+
email: "invalid-no-at",
344+
};
345+
const originalB = createManagedAccount(1);
346+
const liveA = {
347+
...originalA,
348+
index: 1,
349+
};
350+
const liveB = { ...originalB, index: 0 };
351+
const snapshots = [
352+
[originalA, originalB],
353+
[liveB, liveA],
354+
];
355+
let readCount = 0;
356+
const manager = {
357+
getAccountsSnapshot: vi.fn(
358+
() => snapshots[Math.min(readCount++, snapshots.length - 1)],
359+
),
360+
getAccountByIndex: vi.fn(
361+
(index: number) =>
362+
[liveB, liveA].find((account) => account.index === index) ?? null,
363+
),
364+
clearAuthFailures: vi.fn(),
365+
markAccountCoolingDown: vi.fn(),
366+
setAccountEnabled: vi.fn(),
367+
saveToDiskDebounced: vi.fn(),
368+
} as unknown as AccountManager;
369+
const { RefreshGuardian } = await import("../lib/refresh-guardian.js");
370+
const guardian = new RefreshGuardian(() => manager, {
371+
bufferMs: 60_000,
372+
intervalMs: 5_000,
373+
});
374+
375+
refreshExpiringAccountsMock.mockResolvedValue(
376+
new Map([
377+
[
378+
0,
379+
{
380+
refreshed: true,
381+
reason: "success",
382+
tokenResult: {
383+
type: "success",
384+
access: "access-token",
385+
refresh: "refresh-token",
386+
expires: Date.now() + 3_600_000,
387+
},
388+
},
389+
],
390+
]),
391+
);
392+
393+
await guardian.tick();
394+
395+
expect(applyRefreshResultMock).toHaveBeenCalledWith(
396+
liveA,
397+
expect.objectContaining({ type: "success" }),
398+
);
399+
expect(
400+
manager.clearAuthFailures as ReturnType<typeof vi.fn>,
401+
).toHaveBeenCalledWith(liveA);
402+
});
403+
404+
it("treats empty string accountId the same as undefined by using normalized email", async () => {
405+
const originalA = { ...createManagedAccount(0), accountId: "", email: " User0@Example.com " };
341406
const originalB = createManagedAccount(1);
342407
const liveA = {
343408
...originalA,
@@ -642,6 +707,9 @@ describe("refresh-guardian", () => {
642707
expect(
643708
manager.saveToDiskDebounced as ReturnType<typeof vi.fn>,
644709
).toHaveBeenCalledTimes(1);
710+
expect(
711+
manager.getAccountsSnapshot as ReturnType<typeof vi.fn>,
712+
).toHaveBeenCalledTimes(2);
645713

646714
const stats = guardian.getStats();
647715
expect(stats.runs).toBe(1);

0 commit comments

Comments
 (0)