From d9ff1940cc577c6c0c131add7945ee4cc2500984 Mon Sep 17 00:00:00 2001 From: Artur Do Lago Date: Sun, 11 Jan 2026 13:21:59 +0100 Subject: [PATCH] test(auth): add comprehensive auth module tests - Test pure functions: isExpiringSoon, isExpired - Test file operations: get, all, set, remove - Test status checking for multiple providers - Test schema validation for OAuth, API, WellKnown types - Test edge cases: missing files, invalid JSON, extra fields 44 tests covering auth storage and validation. Co-Authored-By: Claude Opus 4.5 --- packages/agent-core/test/auth/auth.test.ts | 624 +++++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 packages/agent-core/test/auth/auth.test.ts diff --git a/packages/agent-core/test/auth/auth.test.ts b/packages/agent-core/test/auth/auth.test.ts new file mode 100644 index 00000000000..15faa0933b8 --- /dev/null +++ b/packages/agent-core/test/auth/auth.test.ts @@ -0,0 +1,624 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { Auth } from "../../src/auth" +import { Global } from "../../src/global" + +// Helper to create test auth file +async function writeAuthFile(data: Record) { + const filepath = path.join(Global.Path.data, "auth.json") + await fs.mkdir(path.dirname(filepath), { recursive: true }) + await Bun.write(filepath, JSON.stringify(data, null, 2)) + await fs.chmod(filepath, 0o600) +} + +// Helper to read auth file directly +async function readAuthFile(): Promise> { + const filepath = path.join(Global.Path.data, "auth.json") + const file = Bun.file(filepath) + return file.json().catch(() => ({})) +} + +// Helper to clean up auth file +async function cleanupAuthFile() { + const filepath = path.join(Global.Path.data, "auth.json") + await fs.rm(filepath, { force: true }) +} + +describe("Auth.isExpiringSoon", () => { + test("returns false for non-oauth auth", () => { + const apiAuth: Auth.Info = { type: "api", key: "test-key" } + expect(Auth.isExpiringSoon(apiAuth)).toBe(false) + }) + + test("returns false for oauth token not expiring soon", () => { + const oauthAuth: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60 * 60 * 1000, // 1 hour from now + } + expect(Auth.isExpiringSoon(oauthAuth)).toBe(false) + }) + + test("returns true for oauth token expiring within buffer (10 min)", () => { + const oauthAuth: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 5 * 60 * 1000, // 5 minutes from now + } + expect(Auth.isExpiringSoon(oauthAuth)).toBe(true) + }) + + test("returns true for already expired token", () => { + const oauthAuth: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() - 1000, // 1 second ago + } + expect(Auth.isExpiringSoon(oauthAuth)).toBe(true) + }) +}) + +describe("Auth.isExpired", () => { + test("returns false for non-oauth auth", () => { + const apiAuth: Auth.Info = { type: "api", key: "test-key" } + expect(Auth.isExpired(apiAuth)).toBe(false) + }) + + test("returns false for valid oauth token", () => { + const oauthAuth: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60 * 60 * 1000, // 1 hour from now + } + expect(Auth.isExpired(oauthAuth)).toBe(false) + }) + + test("returns true for expired oauth token", () => { + const oauthAuth: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() - 1000, // 1 second ago + } + expect(Auth.isExpired(oauthAuth)).toBe(true) + }) + + test("returns false for token expiring soon but not yet expired", () => { + const oauthAuth: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 5 * 60 * 1000, // 5 minutes from now + } + expect(Auth.isExpired(oauthAuth)).toBe(false) + }) +}) + +describe("Auth file operations", () => { + beforeEach(async () => { + await cleanupAuthFile() + }) + + afterEach(async () => { + await cleanupAuthFile() + }) + + describe("Auth.all", () => { + test("returns empty object when auth file does not exist", async () => { + const result = await Auth.all() + expect(result).toEqual({}) + }) + + test("returns empty object when auth file is invalid JSON", async () => { + const filepath = path.join(Global.Path.data, "auth.json") + await fs.mkdir(path.dirname(filepath), { recursive: true }) + await Bun.write(filepath, "not valid json") + + const result = await Auth.all() + expect(result).toEqual({}) + }) + + test("parses valid oauth entries", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: 1234567890000, + }, + }) + + const result = await Auth.all() + expect(result.anthropic).toBeDefined() + expect(result.anthropic.type).toBe("oauth") + expect((result.anthropic as any).access).toBe("access-token") + }) + + test("parses valid api entries", async () => { + await writeAuthFile({ + openrouter: { + type: "api", + key: "sk-or-v1-test", + }, + }) + + const result = await Auth.all() + expect(result.openrouter).toBeDefined() + expect(result.openrouter.type).toBe("api") + expect((result.openrouter as any).key).toBe("sk-or-v1-test") + }) + + test("parses valid wellknown entries", async () => { + await writeAuthFile({ + custom: { + type: "wellknown", + key: "well-known-key", + token: "well-known-token", + }, + }) + + const result = await Auth.all() + expect(result.custom).toBeDefined() + expect(result.custom.type).toBe("wellknown") + }) + + test("skips invalid entries", async () => { + await writeAuthFile({ + valid: { + type: "api", + key: "valid-key", + }, + invalid: { + type: "unknown", + foo: "bar", + }, + alsoInvalid: "not an object", + }) + + const result = await Auth.all() + expect(result.valid).toBeDefined() + expect(result.invalid).toBeUndefined() + expect(result.alsoInvalid).toBeUndefined() + }) + + test("preserves extra fields on oauth (passthrough)", async () => { + await writeAuthFile({ + google: { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: 1234567890000, + email: "user@example.com", + projectId: "project-123", + }, + }) + + const result = await Auth.all() + expect(result.google).toBeDefined() + expect((result.google as any).email).toBe("user@example.com") + expect((result.google as any).projectId).toBe("project-123") + }) + }) + + describe("Auth.get", () => { + test("returns undefined for non-existent provider", async () => { + const result = await Auth.get("nonexistent") + expect(result).toBeUndefined() + }) + + test("returns auth info for existing provider", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: 1234567890000, + }, + }) + + const result = await Auth.get("anthropic") + expect(result).toBeDefined() + expect(result?.type).toBe("oauth") + }) + }) + + describe("Auth.set", () => { + test("creates auth file if it does not exist", async () => { + await Auth.set("newprovider", { + type: "api", + key: "new-key", + }) + + const data = await readAuthFile() + expect(data.newprovider).toBeDefined() + expect((data.newprovider as any).key).toBe("new-key") + }) + + test("adds entry to existing auth file", async () => { + await writeAuthFile({ + existing: { + type: "api", + key: "existing-key", + }, + }) + + await Auth.set("newprovider", { + type: "api", + key: "new-key", + }) + + const data = await readAuthFile() + expect(data.existing).toBeDefined() + expect(data.newprovider).toBeDefined() + }) + + test("overwrites existing entry", async () => { + await writeAuthFile({ + provider: { + type: "api", + key: "old-key", + }, + }) + + await Auth.set("provider", { + type: "api", + key: "new-key", + }) + + const data = await readAuthFile() + expect((data.provider as any).key).toBe("new-key") + }) + + test("sets correct file permissions (0o600)", async () => { + await Auth.set("provider", { + type: "api", + key: "secret-key", + }) + + const filepath = path.join(Global.Path.data, "auth.json") + const stats = await fs.stat(filepath) + // Check that only owner has read/write permissions + expect(stats.mode & 0o777).toBe(0o600) + }) + }) + + describe("Auth.remove", () => { + test("removes existing entry", async () => { + await writeAuthFile({ + toRemove: { + type: "api", + key: "key", + }, + toKeep: { + type: "api", + key: "other-key", + }, + }) + + await Auth.remove("toRemove") + + const data = await readAuthFile() + expect(data.toRemove).toBeUndefined() + expect(data.toKeep).toBeDefined() + }) + + test("does nothing for non-existent entry", async () => { + await writeAuthFile({ + existing: { + type: "api", + key: "key", + }, + }) + + await Auth.remove("nonexistent") + + const data = await readAuthFile() + expect(data.existing).toBeDefined() + }) + }) +}) + +describe("Auth.status", () => { + beforeEach(async () => { + await cleanupAuthFile() + }) + + afterEach(async () => { + await cleanupAuthFile() + }) + + test("returns empty object when no auth entries", async () => { + const result = await Auth.status() + expect(result).toEqual({}) + }) + + test("returns valid=true for non-expired oauth token", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60 * 60 * 1000, // 1 hour + }, + }) + + const result = await Auth.status() + expect(result.anthropic.valid).toBe(true) + expect(result.anthropic.expiringSoon).toBe(false) + expect(result.anthropic.expiresIn).toBeGreaterThan(0) + }) + + test("returns valid=false for expired oauth token", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() - 1000, // 1 second ago + }, + }) + + const result = await Auth.status() + expect(result.anthropic.valid).toBe(false) + expect(result.anthropic.expiresIn).toBeLessThan(0) + }) + + test("returns expiringSoon=true for token expiring within 10 min", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 5 * 60 * 1000, // 5 minutes + }, + }) + + const result = await Auth.status() + expect(result.anthropic.valid).toBe(true) + expect(result.anthropic.expiringSoon).toBe(true) + }) + + test("returns valid=true with null expiresIn for api auth", async () => { + await writeAuthFile({ + openrouter: { + type: "api", + key: "sk-or-key", + }, + }) + + const result = await Auth.status() + expect(result.openrouter.valid).toBe(true) + expect(result.openrouter.expiringSoon).toBe(false) + expect(result.openrouter.expiresIn).toBeNull() + }) + + test("handles multiple providers", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60 * 60 * 1000, + }, + openrouter: { + type: "api", + key: "key", + }, + expired: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() - 1000, + }, + }) + + const result = await Auth.status() + expect(Object.keys(result)).toHaveLength(3) + expect(result.anthropic.valid).toBe(true) + expect(result.openrouter.valid).toBe(true) + expect(result.expired.valid).toBe(false) + }) +}) + +describe("Auth.refreshToken", () => { + beforeEach(async () => { + await cleanupAuthFile() + }) + + afterEach(async () => { + await cleanupAuthFile() + }) + + test("returns false for non-existent provider", async () => { + const result = await Auth.refreshToken("nonexistent") + expect(result).toBe(false) + }) + + test("returns false for api auth (non-oauth)", async () => { + await writeAuthFile({ + openrouter: { + type: "api", + key: "key", + }, + }) + + const result = await Auth.refreshToken("openrouter") + expect(result).toBe(false) + }) + + test("returns false for provider without refresh config", async () => { + await writeAuthFile({ + google: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 1000, + }, + }) + + // google uses a custom flow, not standard OAuth refresh + const result = await Auth.refreshToken("google") + expect(result).toBe(false) + }) + + // Network tests would require mocking fetch + // Skipping actual refresh tests as they require network access +}) + +describe("Auth.refreshAllExpiring", () => { + beforeEach(async () => { + await cleanupAuthFile() + }) + + afterEach(async () => { + await cleanupAuthFile() + }) + + test("returns empty arrays when no tokens are expiring", async () => { + await writeAuthFile({ + anthropic: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60 * 60 * 1000, // 1 hour (not expiring soon) + }, + }) + + const result = await Auth.refreshAllExpiring() + expect(result.refreshed).toEqual([]) + expect(result.failed).toEqual([]) + }) + + test("returns empty arrays when no auth entries", async () => { + const result = await Auth.refreshAllExpiring() + expect(result.refreshed).toEqual([]) + expect(result.failed).toEqual([]) + }) + + test("skips api auth entries", async () => { + await writeAuthFile({ + openrouter: { + type: "api", + key: "key", + }, + }) + + const result = await Auth.refreshAllExpiring() + expect(result.refreshed).toEqual([]) + expect(result.failed).toEqual([]) + }) + + test("skips providers without refresh config", async () => { + await writeAuthFile({ + google: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 1000, // Expiring soon but no refresh config + }, + }) + + const result = await Auth.refreshAllExpiring() + expect(result.refreshed).toEqual([]) + expect(result.failed).toEqual([]) + }) +}) + +describe("Auth schema validation", () => { + test("Oauth schema validates correctly", () => { + const valid = Auth.Oauth.safeParse({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: 1234567890000, + }) + expect(valid.success).toBe(true) + }) + + test("Oauth schema allows extra fields", () => { + const valid = Auth.Oauth.safeParse({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: 1234567890000, + email: "user@example.com", + customField: "custom-value", + }) + expect(valid.success).toBe(true) + if (valid.success) { + expect((valid.data as any).email).toBe("user@example.com") + } + }) + + test("Oauth schema rejects missing required fields", () => { + const invalid = Auth.Oauth.safeParse({ + type: "oauth", + refresh: "refresh-token", + // missing access and expires + }) + expect(invalid.success).toBe(false) + }) + + test("Api schema validates correctly", () => { + const valid = Auth.Api.safeParse({ + type: "api", + key: "sk-api-key", + }) + expect(valid.success).toBe(true) + }) + + test("Api schema rejects missing key", () => { + const invalid = Auth.Api.safeParse({ + type: "api", + }) + expect(invalid.success).toBe(false) + }) + + test("WellKnown schema validates correctly", () => { + const valid = Auth.WellKnown.safeParse({ + type: "wellknown", + key: "well-known-key", + token: "well-known-token", + }) + expect(valid.success).toBe(true) + }) + + test("Info union discriminates correctly", () => { + const oauth = Auth.Info.safeParse({ + type: "oauth", + refresh: "r", + access: "a", + expires: 123, + }) + const api = Auth.Info.safeParse({ + type: "api", + key: "k", + }) + const wellknown = Auth.Info.safeParse({ + type: "wellknown", + key: "k", + token: "t", + }) + + expect(oauth.success).toBe(true) + expect(api.success).toBe(true) + expect(wellknown.success).toBe(true) + + if (oauth.success) expect(oauth.data.type).toBe("oauth") + if (api.success) expect(api.data.type).toBe("api") + if (wellknown.success) expect(wellknown.data.type).toBe("wellknown") + }) + + test("Info union rejects unknown type", () => { + const invalid = Auth.Info.safeParse({ + type: "unknown", + data: "whatever", + }) + expect(invalid.success).toBe(false) + }) +})