mirror of
https://github.com/NoeFabris/opencode-antigravity-auth.git
synced 2026-05-21 12:54:56 +00:00
326 lines
10 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|
|
});
|