Files
opencode-antigravity-auth/src/plugin/storage.test.ts

326 lines
10 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import { deduplicateAccountsByEmail, migrateV2ToV3, loadAccounts, type AccountMetadata, type AccountStorage } from "./storage";
import { promises as fs } from "node:fs";
vi.mock("proper-lockfile", () => ({
default: {
lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)),
},
}));
describe("deduplicateAccountsByEmail", () => {
it("returns empty array for empty input", () => {
const result = deduplicateAccountsByEmail([]);
expect(result).toEqual([]);
});
it("returns single account unchanged", () => {
const accounts: AccountMetadata[] = [
{ email: "test@example.com", refreshToken: "r1", addedAt: 1000, lastUsed: 2000 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toEqual(accounts);
});
it("keeps accounts without email (cannot deduplicate)", () => {
const accounts: AccountMetadata[] = [
{ refreshToken: "r1", addedAt: 1000, lastUsed: 2000 },
{ refreshToken: "r2", addedAt: 1100, lastUsed: 2100 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(2);
expect(result[0]?.refreshToken).toBe("r1");
expect(result[1]?.refreshToken).toBe("r2");
});
it("deduplicates accounts with same email, keeping newest by lastUsed", () => {
const accounts: AccountMetadata[] = [
{ email: "test@example.com", refreshToken: "old-token", addedAt: 1000, lastUsed: 1000 },
{ email: "test@example.com", refreshToken: "new-token", addedAt: 2000, lastUsed: 3000 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(1);
expect(result[0]?.refreshToken).toBe("new-token");
expect(result[0]?.email).toBe("test@example.com");
});
it("deduplicates accounts with same email, keeping newest by addedAt when lastUsed is equal", () => {
const accounts: AccountMetadata[] = [
{ email: "test@example.com", refreshToken: "old-token", addedAt: 1000, lastUsed: 0 },
{ email: "test@example.com", refreshToken: "new-token", addedAt: 2000, lastUsed: 0 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(1);
expect(result[0]?.refreshToken).toBe("new-token");
});
it("handles multiple duplicate emails correctly", () => {
const accounts: AccountMetadata[] = [
{ email: "alice@example.com", refreshToken: "alice-old", addedAt: 1000, lastUsed: 1000 },
{ email: "bob@example.com", refreshToken: "bob-old", addedAt: 1000, lastUsed: 1000 },
{ email: "alice@example.com", refreshToken: "alice-new", addedAt: 2000, lastUsed: 3000 },
{ email: "bob@example.com", refreshToken: "bob-new", addedAt: 2000, lastUsed: 3000 },
{ email: "alice@example.com", refreshToken: "alice-mid", addedAt: 1500, lastUsed: 2000 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(2);
const alice = result.find(a => a.email === "alice@example.com");
const bob = result.find(a => a.email === "bob@example.com");
expect(alice?.refreshToken).toBe("alice-new");
expect(bob?.refreshToken).toBe("bob-new");
});
it("preserves order of kept accounts based on newest entry index", () => {
const accounts: AccountMetadata[] = [
{ email: "first@example.com", refreshToken: "first-old", addedAt: 1000, lastUsed: 1000 },
{ email: "second@example.com", refreshToken: "second-new", addedAt: 3000, lastUsed: 3000 },
{ email: "first@example.com", refreshToken: "first-new", addedAt: 2000, lastUsed: 2000 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(2);
// Kept entries are at indices 1 (second@) and 2 (first@), so order is second, first
expect(result[0]?.email).toBe("second@example.com");
expect(result[1]?.email).toBe("first@example.com");
});
it("mixes accounts with and without email correctly", () => {
const accounts: AccountMetadata[] = [
{ email: "test@example.com", refreshToken: "r1", addedAt: 1000, lastUsed: 1000 },
{ refreshToken: "no-email-1", addedAt: 1500, lastUsed: 1500 },
{ email: "test@example.com", refreshToken: "r2", addedAt: 2000, lastUsed: 2000 },
{ refreshToken: "no-email-2", addedAt: 2500, lastUsed: 2500 },
];
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(3);
// no-email-1 at index 1
// r2 (newest for test@example.com) at index 2
// no-email-2 at index 3
expect(result[0]?.refreshToken).toBe("no-email-1");
expect(result[1]?.refreshToken).toBe("r2");
expect(result[2]?.refreshToken).toBe("no-email-2");
});
it("handles exact scenario from issue #24 (11 duplicate accounts)", () => {
// Simulate user logging in 11 times with the same account
const accounts: AccountMetadata[] = [];
for (let i = 0; i < 11; i++) {
accounts.push({
email: "user@example.com",
refreshToken: `token-${i}`,
addedAt: 1000 + i * 100,
lastUsed: 1000 + i * 100,
});
}
const result = deduplicateAccountsByEmail(accounts);
expect(result).toHaveLength(1);
expect(result[0]?.refreshToken).toBe("token-10"); // The newest one
expect(result[0]?.email).toBe("user@example.com");
});
});
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
promises: {
...actual.promises,
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn(),
rename: vi.fn().mockResolvedValue(undefined),
},
};
});
describe("Storage Migration", () => {
const now = Date.now();
const future = now + 100000;
const past = now - 100000;
describe("migrateV2ToV3", () => {
it("converts gemini rate limits to gemini-antigravity", () => {
const v2: AccountStorage = {
version: 2,
accounts: [
{
refreshToken: "r1",
addedAt: now,
lastUsed: now,
rateLimitResetTimes: {
gemini: future,
},
},
],
activeIndex: 0,
};
const v3 = migrateV2ToV3(v2);
expect(v3.version).toBe(3);
const account = v3.accounts[0];
if (!account) throw new Error("Account not found");
expect(account.rateLimitResetTimes).toEqual({
"gemini-antigravity": future,
});
expect(account.rateLimitResetTimes?.["gemini-cli"]).toBeUndefined();
});
it("preserves claude rate limits", () => {
const v2: AccountStorage = {
version: 2,
accounts: [
{
refreshToken: "r1",
addedAt: now,
lastUsed: now,
rateLimitResetTimes: {
claude: future,
},
},
],
activeIndex: 0,
};
const v3 = migrateV2ToV3(v2);
const account = v3.accounts[0];
if (!account) throw new Error("Account not found");
expect(account.rateLimitResetTimes).toEqual({
claude: future,
});
});
it("handles mixed rate limits correctly", () => {
const v2: AccountStorage = {
version: 2,
accounts: [
{
refreshToken: "r1",
addedAt: now,
lastUsed: now,
rateLimitResetTimes: {
claude: future,
gemini: future,
},
},
],
activeIndex: 0,
};
const v3 = migrateV2ToV3(v2);
const account = v3.accounts[0];
if (!account) throw new Error("Account not found");
expect(account.rateLimitResetTimes).toEqual({
claude: future,
"gemini-antigravity": future,
});
});
it("filters out expired rate limits", () => {
const v2: AccountStorage = {
version: 2,
accounts: [
{
refreshToken: "r1",
addedAt: now,
lastUsed: now,
rateLimitResetTimes: {
claude: past,
gemini: future,
},
},
],
activeIndex: 0,
};
const v3 = migrateV2ToV3(v2);
const account = v3.accounts[0];
if (!account) throw new Error("Account not found");
expect(account.rateLimitResetTimes).toEqual({
"gemini-antigravity": future,
});
expect(account.rateLimitResetTimes?.claude).toBeUndefined();
});
it("removes rateLimitResetTimes object if all keys are expired", () => {
const v2: AccountStorage = {
version: 2,
accounts: [
{
refreshToken: "r1",
addedAt: now,
lastUsed: now,
rateLimitResetTimes: {
claude: past,
gemini: past,
},
},
],
activeIndex: 0,
};
const v3 = migrateV2ToV3(v2);
const account = v3.accounts[0];
if (!account) throw new Error("Account not found");
expect(account.rateLimitResetTimes).toBeUndefined();
});
});
describe("loadAccounts migration integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("migrates V2 storage on load and persists V3", async () => {
const v2Data = {
version: 2,
accounts: [
{
refreshToken: "r1",
addedAt: now,
lastUsed: now,
rateLimitResetTimes: {
gemini: future,
},
},
],
activeIndex: 0,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(v2Data));
const result = await loadAccounts();
expect(result).not.toBeNull();
expect(result?.version).toBe(3);
const account = result?.accounts[0];
if (!account) throw new Error("Account not found");
expect(account.rateLimitResetTimes).toEqual({
"gemini-antigravity": future,
});
expect(fs.writeFile).toHaveBeenCalled();
const saveCall = vi.mocked(fs.writeFile).mock.calls[0];
if (!saveCall) throw new Error("saveAccounts was not called");
const savedContent = JSON.parse(saveCall[1] as string);
expect(savedContent.version).toBe(3);
expect(savedContent.accounts[0].rateLimitResetTimes).toEqual({
"gemini-antigravity": future,
});
});
});
});