Skip to content

Commit d9a0ea3

Browse files
committed
fix: cover sync prune restore after sync failure
1 parent b213a60 commit d9a0ea3

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

lib/sync-prune-backup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export function createSyncPruneBackupPayload<TFlaggedAccount extends object>(
1313
accounts: AccountStorageV3;
1414
flagged: FlaggedSnapshot<TFlaggedAccount>;
1515
} {
16+
// Intentionally retain live tokens so a mid-sync crash can fully restore pruned accounts.
17+
// The backup is stored under the user's config home; on Windows its ACLs are the real boundary
18+
// because the later write path's `mode: 0o600` hint is not strictly enforced there.
1619
return {
1720
version: 1,
1821
accounts: structuredClone({

test/index.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2400,6 +2400,112 @@ describe("OpenAIOAuthPlugin", () => {
24002400
expect(vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction)).toHaveBeenCalled();
24012401
expect(vi.mocked(storageModule.withFlaggedAccountsTransaction)).not.toHaveBeenCalled();
24022402
});
2403+
2404+
it("restores sync prune backup when sync fails after successful prune removal", async () => {
2405+
const cliModule = await import("../lib/cli.js");
2406+
const confirmModule = await import("../lib/ui/confirm.js");
2407+
const configModule = await import("../lib/config.js");
2408+
const storageModule = await import("../lib/storage.js");
2409+
const syncModule = await import("../lib/codex-multi-auth-sync.js");
2410+
2411+
mockStorage.accounts = [
2412+
{
2413+
accountId: "remove-me",
2414+
email: "remove@example.com",
2415+
refreshToken: "refresh-remove",
2416+
accessToken: "access-remove",
2417+
idToken: "id-remove",
2418+
addedAt: 1,
2419+
lastUsed: 1,
2420+
},
2421+
{
2422+
accountId: "keep-me",
2423+
email: "keep@example.com",
2424+
refreshToken: "refresh-keep",
2425+
accessToken: "access-keep",
2426+
idToken: "id-keep",
2427+
addedAt: 2,
2428+
lastUsed: 2,
2429+
},
2430+
];
2431+
mockStorage.activeIndex = 1;
2432+
mockStorage.activeIndexByFamily = { codex: 1 };
2433+
mockFlaggedStorage.accounts = [
2434+
{
2435+
accountId: "remove-me",
2436+
email: "remove@example.com",
2437+
refreshToken: "refresh-remove",
2438+
accessToken: "flagged-access-remove",
2439+
idToken: "flagged-id-remove",
2440+
flaggedAt: 123,
2441+
addedAt: 1,
2442+
lastUsed: 1,
2443+
},
2444+
];
2445+
2446+
const originalAccounts = cloneMockStorage();
2447+
const originalFlagged = cloneMockFlaggedStorage();
2448+
const { CodexMultiAuthSyncCapacityError } = syncModule;
2449+
vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true);
2450+
vi.mocked(cliModule.promptLoginMode)
2451+
.mockResolvedValueOnce({ mode: "experimental-sync-now" })
2452+
.mockResolvedValueOnce({ mode: "cancel" })
2453+
.mockResolvedValue({ mode: "cancel" });
2454+
vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]);
2455+
vi.mocked(confirmModule.confirm).mockResolvedValue(true);
2456+
vi.mocked(syncModule.previewSyncFromCodexMultiAuth)
2457+
.mockRejectedValueOnce(
2458+
new CodexMultiAuthSyncCapacityError({
2459+
rootDir: "/tmp/codex-source",
2460+
accountsPath: "/tmp/codex-source/openai-codex-accounts.json",
2461+
scope: "global",
2462+
currentCount: 2,
2463+
sourceCount: 1,
2464+
sourceDedupedTotal: 1,
2465+
dedupedTotal: 3,
2466+
maxAccounts: 2,
2467+
needToRemove: 1,
2468+
importableNewAccounts: 1,
2469+
skippedOverlaps: 0,
2470+
suggestedRemovals: [
2471+
{
2472+
index: 0,
2473+
email: "remove@example.com",
2474+
accountLabel: "Remove Me",
2475+
refreshToken: "refresh-remove",
2476+
organizationId: undefined,
2477+
accountId: "remove-me",
2478+
isCurrentAccount: false,
2479+
score: 100,
2480+
reason: "test removal",
2481+
},
2482+
],
2483+
}),
2484+
)
2485+
.mockResolvedValueOnce({
2486+
rootDir: "/tmp/codex-source",
2487+
accountsPath: "/tmp/codex-source/openai-codex-accounts.json",
2488+
scope: "global",
2489+
imported: 1,
2490+
skipped: 0,
2491+
total: 2,
2492+
});
2493+
vi.mocked(syncModule.syncFromCodexMultiAuth).mockRejectedValueOnce(
2494+
new Error("sync failed after prune"),
2495+
);
2496+
2497+
const autoMethod = plugin.auth.methods[0] as unknown as {
2498+
authorize: (inputs?: Record<string, string>) => Promise<{ instructions: string }>;
2499+
};
2500+
2501+
const result = await autoMethod.authorize();
2502+
expect(result.instructions).toBe("Authentication cancelled");
2503+
expect(mockStorage).toMatchObject(originalAccounts);
2504+
expect(mockFlaggedStorage).toMatchObject(originalFlagged);
2505+
expect(vi.mocked(storageModule.loadAccountAndFlaggedStorageSnapshot)).toHaveBeenCalledTimes(1);
2506+
expect(vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction)).toHaveBeenCalledTimes(2);
2507+
expect(vi.mocked(syncModule.syncFromCodexMultiAuth)).toHaveBeenCalledTimes(1);
2508+
});
24032509
});
24042510
});
24052511

0 commit comments

Comments
 (0)