Files
pi_harness/packages/coding-agent/test/auth-storage.test.ts
T
Mario Zechner 9993c96907 feat(coding-agent): replace model registry with model runtime
Move provider auth and OAuth flows onto pi-ai Models, compose models.json and extension overlays through ModelRuntime, and retain ModelRegistry as an extension compatibility facade.
2026-07-14 17:48:45 +02:00

218 lines
8.1 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createModels, type Provider } from "@earendil-works/pi-ai";
import lockfile from "proper-lockfile";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
describe("AuthStorage", () => {
let tempDir: string;
let authJsonPath: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-test-auth-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
authJsonPath = join(tempDir, "auth.json");
});
afterEach(() => {
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
vi.restoreAllMocks();
});
function writeAuthJson(data: Record<string, unknown>): void {
writeFileSync(authJsonPath, JSON.stringify(data));
}
test("reads and resolves stored API-key credentials", async () => {
const original = process.env.TEST_AUTH_STORAGE_KEY;
process.env.TEST_AUTH_STORAGE_KEY = "environment-key";
try {
writeAuthJson({ anthropic: { type: "api_key", key: "$TEST_AUTH_STORAGE_KEY" } });
const storage = AuthStorage.create(authJsonPath);
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "environment-key" });
} finally {
if (original === undefined) delete process.env.TEST_AUTH_STORAGE_KEY;
else process.env.TEST_AUTH_STORAGE_KEY = original;
}
});
test("resolves command-backed API-key credentials", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "!printf 'command-key'" } });
const storage = AuthStorage.create(authJsonPath);
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "command-key" });
});
test("returns OAuth credentials unchanged", async () => {
const credential = {
type: "oauth" as const,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
};
const storage = AuthStorage.inMemory({ anthropic: credential });
expect(await storage.read("anthropic")).toEqual(credential);
});
test("credential-scoped env takes precedence and remains inspectable", async () => {
writeAuthJson({
anthropic: {
type: "api_key",
key: "$SCOPED_KEY",
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
},
});
const storage = AuthStorage.create(authJsonPath);
expect(await storage.read("anthropic")).toMatchObject({
key: "scoped-value",
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
});
});
test("modify persists a credential while preserving unrelated external edits", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
const storage = AuthStorage.create(authJsonPath);
writeAuthJson({
anthropic: { type: "api_key", key: "old" },
openai: { type: "api_key", key: "external" },
});
await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" }));
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "new" },
openai: { type: "api_key", key: "external" },
});
});
test("modify with undefined leaves the current credential unchanged", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
const storage = AuthStorage.create(authJsonPath);
expect(await storage.modify("anthropic", async () => undefined)).toEqual({ type: "api_key", key: "stored" });
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "stored" });
});
test("serializes concurrent modifications", async () => {
writeAuthJson({});
const first = AuthStorage.create(authJsonPath);
const second = AuthStorage.create(authJsonPath);
await Promise.all([
first.modify("anthropic", async () => ({ type: "api_key", key: "anthropic-key" })),
second.modify("openai", async () => ({ type: "api_key", key: "openai-key" })),
]);
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
});
});
test("delete removes one credential while preserving others", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
});
const storage = AuthStorage.create(authJsonPath);
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
google: { type: "api_key", key: "external-key" },
});
await storage.delete("anthropic");
await expect(storage.list()).resolves.toEqual([
{ providerId: "openai", type: "api_key" },
{ providerId: "google", type: "api_key" },
]);
expect(await storage.read("anthropic")).toBeUndefined();
expect(await storage.read("openai")).toEqual({ type: "api_key", key: "openai-key" });
expect(await storage.read("google")).toEqual({ type: "api_key", key: "external-key" });
});
test("in-memory storage implements the same credential-store behavior", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "initial" } });
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "initial" });
await storage.modify("anthropic", async () => ({ type: "api_key", key: "updated" }));
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "updated" });
await storage.delete("anthropic");
await expect(storage.list()).resolves.toEqual([]);
});
test("does not write after lock acquisition failure and recovers on retry", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
const storage = AuthStorage.create(authJsonPath);
const lockSpy = vi.spyOn(lockfile, "lock").mockRejectedValueOnce(new Error("lock unavailable"));
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow(
"lock unavailable",
);
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "stored" },
});
lockSpy.mockRestore();
await storage.modify("openai", async () => ({ type: "api_key", key: "new" }));
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "stored" },
openai: { type: "api_key", key: "new" },
});
});
test("surfaces a compromised OAuth refresh lock and allows a later retry", async () => {
const providerId = "oauth-provider";
writeAuthJson({
[providerId]: {
type: "oauth",
access: "expired-access",
refresh: "refresh-token",
expires: 0,
},
});
const storage = AuthStorage.create(authJsonPath);
const provider: Provider = {
id: providerId,
name: "OAuth Provider",
auth: {
oauth: {
name: "OAuth",
login: async () => {
throw new Error("not used");
},
refresh: async (credential) => ({
...credential,
access: "refreshed-access",
expires: Date.now() + 60_000,
}),
toAuth: async (credential) => ({ apiKey: credential.access }),
},
},
getModels: () => [],
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
};
const models = createModels({ credentials: storage });
models.setProvider(provider);
const realLock = lockfile.lock.bind(lockfile);
const lockSpy = vi.spyOn(lockfile, "lock").mockImplementationOnce(async (file, options) => {
options?.onCompromised?.(new Error("lock compromised"));
return realLock(file, options);
});
await expect(models.getAuth(providerId)).rejects.toMatchObject({ code: "auth" });
lockSpy.mockRestore();
await expect(models.getAuth(providerId)).resolves.toMatchObject({ auth: { apiKey: "refreshed-access" } });
});
test("does not overwrite malformed auth files", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
const storage = AuthStorage.create(authJsonPath);
writeFileSync(authJsonPath, "{invalid-json", "utf8");
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow();
expect(readFileSync(authJsonPath, "utf8")).toBe("{invalid-json");
});
});