fix(sync): merge auth providers across all candidate files
readStoredAuthProvidersFromDisk() previously returned only the first successfully-parsed auth file, missing providers that existed only in fallback locations (e.g. github-copilot in ~/.pi/agent/auth.json when another provider was in ~/.fusion/agent/auth.json). Now iterates all candidates and merges entries with first-found-wins priority.
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
// Mock getAuthFileCandidates to control which paths are checked
|
||||||
|
const mockAuthCandidates: string[] = [];
|
||||||
|
vi.mock("../auth-paths.js", () => ({
|
||||||
|
getAuthFileCandidates: () => mockAuthCandidates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import after mock
|
||||||
|
const { readStoredAuthProvidersFromDisk } = await import(
|
||||||
|
"../routes/register-settings-sync-helpers.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("readStoredAuthProvidersFromDisk", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = join(tmpdir(), `fn-sync-helpers-test-${Date.now()}`);
|
||||||
|
await mkdir(tempDir, { recursive: true });
|
||||||
|
mockAuthCandidates.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty object when no auth files exist", async () => {
|
||||||
|
mockAuthCandidates.push(join(tempDir, "nonexistent.json"));
|
||||||
|
const result = await readStoredAuthProvidersFromDisk();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads providers from a single auth file", async () => {
|
||||||
|
const authPath = join(tempDir, "auth.json");
|
||||||
|
await writeFile(
|
||||||
|
authPath,
|
||||||
|
JSON.stringify({
|
||||||
|
openrouter: { type: "api_key", key: "sk-or-123" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockAuthCandidates.push(authPath);
|
||||||
|
|
||||||
|
const result = await readStoredAuthProvidersFromDisk();
|
||||||
|
expect(result).toEqual({
|
||||||
|
openrouter: { type: "api_key", key: "sk-or-123" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges providers across multiple auth files", async () => {
|
||||||
|
const fusionAuth = join(tempDir, "fusion-auth.json");
|
||||||
|
const piAuth = join(tempDir, "pi-auth.json");
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
fusionAuth,
|
||||||
|
JSON.stringify({
|
||||||
|
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||||
|
github: { type: "oauth", access: "gh-fusion-token" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
piAuth,
|
||||||
|
JSON.stringify({
|
||||||
|
minimax: { type: "api_key", key: "mm-pi-key" },
|
||||||
|
github: { type: "oauth", access: "gh-pi-token" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAuthCandidates.push(fusionAuth, piAuth);
|
||||||
|
const result = await readStoredAuthProvidersFromDisk();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||||
|
github: { type: "oauth", access: "gh-fusion-token" },
|
||||||
|
minimax: { type: "api_key", key: "mm-pi-key" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives priority to first-found file for duplicate providers", async () => {
|
||||||
|
const firstAuth = join(tempDir, "first.json");
|
||||||
|
const secondAuth = join(tempDir, "second.json");
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
firstAuth,
|
||||||
|
JSON.stringify({ openrouter: { type: "api_key", key: "first-key" } }),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
secondAuth,
|
||||||
|
JSON.stringify({ openrouter: { type: "api_key", key: "second-key" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAuthCandidates.push(firstAuth, secondAuth);
|
||||||
|
const result = await readStoredAuthProvidersFromDisk();
|
||||||
|
|
||||||
|
expect(result.openrouter).toEqual({ type: "api_key", key: "first-key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips invalid JSON files and continues to next candidate", async () => {
|
||||||
|
const badAuth = join(tempDir, "bad.json");
|
||||||
|
const goodAuth = join(tempDir, "good.json");
|
||||||
|
|
||||||
|
await writeFile(badAuth, "not valid json{{{");
|
||||||
|
await writeFile(
|
||||||
|
goodAuth,
|
||||||
|
JSON.stringify({ openrouter: { type: "api_key", key: "sk-good" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAuthCandidates.push(badAuth, goodAuth);
|
||||||
|
const result = await readStoredAuthProvidersFromDisk();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
openrouter: { type: "api_key", key: "sk-good" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up github-copilot from fallback when not in primary", async () => {
|
||||||
|
const fusionAuth = join(tempDir, "fusion-auth.json");
|
||||||
|
const piAuth = join(tempDir, "pi-auth.json");
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
fusionAuth,
|
||||||
|
JSON.stringify({
|
||||||
|
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
piAuth,
|
||||||
|
JSON.stringify({
|
||||||
|
"github-copilot": { type: "oauth", access: "copilot-token", refresh: "ghu_refresh", expires: 9999999999999 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAuthCandidates.push(fusionAuth, piAuth);
|
||||||
|
const result = await readStoredAuthProvidersFromDisk();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||||
|
"github-copilot": { type: "oauth", access: "copilot-token", refresh: "ghu_refresh", expires: 9999999999999 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,15 +4,19 @@ import { ApiError } from "../api-error.js";
|
|||||||
import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
|
import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
|
||||||
|
|
||||||
export async function readStoredAuthProvidersFromDisk(): Promise<Record<string, StoredAuthProvider>> {
|
export async function readStoredAuthProvidersFromDisk(): Promise<Record<string, StoredAuthProvider>> {
|
||||||
|
const merged: Record<string, StoredAuthProvider> = {};
|
||||||
for (const authJsonPath of getAuthFileCandidates()) {
|
for (const authJsonPath of getAuthFileCandidates()) {
|
||||||
try {
|
try {
|
||||||
const authContent = await fsReadFile(authJsonPath, "utf-8");
|
const authContent = await fsReadFile(authJsonPath, "utf-8");
|
||||||
return JSON.parse(authContent) as Record<string, StoredAuthProvider>;
|
const parsed = JSON.parse(authContent) as Record<string, StoredAuthProvider>;
|
||||||
|
for (const [provider, credential] of Object.entries(parsed)) {
|
||||||
|
merged[provider] ??= credential;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Try next candidate.
|
// Try next candidate.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {};
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user