Files
HealthLog/src/lib/ai/__tests__/provider.test.ts
T
Marc-André Bombeck 1be2392c81 feat(ai): one provider presence probe
probeProviderChain describes the chain that would serve a record
without decrypting, building a client or refreshing a token: which
entries hold a credential, in chain order, whether each reads images,
the in-browser OCR opt-in, and where the credential comes from. It
applies the provider-work authority, appends the central Codex only
behind the opt-in, and falls back to the legacy provider only for an
empty chain. hasAnyConfiguredProvider and resolveProviderAvailability
now answer through it.
2026-09-24 07:39:56 +02:00

445 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((v: string) => `decrypted:${v}`),
encrypt: vi.fn((v: string) => `encrypted:${v}`),
}));
vi.mock("@/lib/ai/codex-oauth", () => ({
refreshDeviceTokens: vi.fn(),
encryptCodexCreds: vi.fn(
(c: { accessToken: string; refreshToken: string; accountId: string }) => ({
accessEncrypted: `enc:${c.accessToken}|${c.accountId}`,
refreshEncrypted: `enc:${c.refreshToken}`,
}),
),
decryptCodexCreds: vi.fn(),
}));
import {
resolveProvider,
resolveProviderForTest,
resolveProviderAvailability,
} from "../provider";
import { OpenAIClient } from "../openai-client";
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { decryptCodexCreds, encryptCodexCreds } from "@/lib/ai/codex-oauth";
/**
* resolveProvider() makes up to TWO findUnique calls per invocation:
* 1. Read user-level AI config (aiProvider/...).
* 2. If falling back to codex, read codex token fields.
* We use mockResolvedValueOnce in order so each call returns the right shape.
*/
describe("resolveProvider", () => {
beforeEach(() => {
// Reset mocks AND mockResolvedValueOnce queues, then re-establish
// the deterministic factory implementations for crypto/oauth helpers.
vi.resetAllMocks();
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
vi.mocked(encrypt).mockImplementation((v: string) => `encrypted:${v}`);
vi.mocked(encryptCodexCreds).mockImplementation(
(c: {
accessToken: string;
refreshToken: string;
accountId: string;
}) => ({
accessEncrypted: `enc:${c.accessToken}|${c.accountId}`,
refreshEncrypted: `enc:${c.refreshToken}`,
}),
);
});
it("returns codex provider when user has valid tokens (no explicit choice)", async () => {
vi.mocked(decryptCodexCreds).mockReturnValue({
accessToken: "oauth-access",
refreshToken: "decrypted-refresh",
accountId: "acct-123",
expiresAt: new Date(Date.now() + 3600_000),
});
vi.mocked(prisma.user.findUnique)
// 1st call: user AI config — none selected
.mockResolvedValueOnce({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never)
// 2nd call: codex tokens
.mockResolvedValueOnce({
codexAccessTokenEncrypted: "enc-access",
codexRefreshTokenEncrypted: "enc-refresh",
codexTokenExpiresAt: new Date(Date.now() + 3600000),
codexConnectionStatus: "connected",
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("codex");
});
it("returns admin-key when user has no codex but admin key exists", async () => {
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never)
.mockResolvedValueOnce({
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin-key",
adminAiModel: "gpt-4o-mini",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("admin-key");
});
it("returns none when nothing is configured", async () => {
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never)
.mockResolvedValueOnce({
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: null,
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("none");
});
it("falls back to admin-key when codex status is expired", async () => {
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never)
.mockResolvedValueOnce({
codexAccessTokenEncrypted: "enc-access",
codexRefreshTokenEncrypted: "enc-refresh",
codexTokenExpiresAt: new Date(Date.now() - 1000),
codexConnectionStatus: "expired",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin",
adminAiModel: "gpt-4o",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("admin-key");
});
it("returns Anthropic provider when user picks ANTHROPIC and has key", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProvider: "ANTHROPIC",
aiModel: "claude-3-5-sonnet-latest",
aiBaseUrl: null,
aiAnthropicKeyEncrypted: "enc-anthropic-key",
aiLocalKeyEncrypted: null,
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("anthropic");
// Codex/admin lookups must NOT happen when user provider resolved.
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
});
it("falls back when user picks ANTHROPIC but has no key", async () => {
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
aiProvider: "ANTHROPIC",
aiModel: "claude-3-5-sonnet-latest",
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never)
.mockResolvedValueOnce({
codexAccessTokenEncrypted: null,
codexConnectionStatus: "disconnected",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin",
adminAiModel: "gpt-4o-mini",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
// ANTHROPIC explicit choice ≠ CHATGPT_OAUTH/null → codex branch is skipped.
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("admin-key");
});
it("returns Local provider when user picks LOCAL with baseUrl", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProvider: "LOCAL",
aiModel: "llama3:8b",
aiBaseUrl: "http://localhost:11434/v1",
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("local");
});
it("OPENAI selection routes through admin OpenAI key", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProvider: "OPENAI",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin",
adminAiModel: "gpt-4o-mini",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("admin-key");
});
it("OPENAI with user key ignores stored aiBaseUrl (provider-leak guard)", async () => {
// Regression for v1.4.6 T4: a user who had configured LOCAL with a
// private LAN URL and later switched to OPENAI must NOT have their
// OpenAI key forwarded to that URL. The persisted `aiBaseUrl` is
// shared across providers, so `buildUserProvider` hardcodes
// api.openai.com for OPENAI regardless of what the column carries.
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProvider: "OPENAI",
aiModel: null,
aiBaseUrl: "http://192.168.0.42/v1", // stale LOCAL value
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: "enc-user-openai-key",
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("admin-key");
const inspect = provider as unknown as {
config: { baseUrl: string };
};
expect(inspect.config.baseUrl).toBe("https://api.openai.com/v1");
});
it("ANTHROPIC ignores stored aiBaseUrl (provider-leak guard)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProvider: "ANTHROPIC",
aiModel: "claude-3-5-sonnet-latest",
aiBaseUrl: "http://192.168.0.42/v1", // stale LOCAL value
aiAnthropicKeyEncrypted: "enc-user-anthropic-key",
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("anthropic");
// AnthropicClient should default to its SDK baseUrl, never the LAN URL.
const inspect = provider as unknown as {
config: { baseUrl?: string };
};
expect(inspect.config.baseUrl).toBeUndefined();
});
it("CHATGPT_OAUTH selection routes through Codex when connected", async () => {
vi.mocked(decryptCodexCreds).mockReturnValue({
accessToken: "oauth-access",
refreshToken: "decrypted-refresh",
accountId: "acct-123",
expiresAt: new Date(Date.now() + 3600_000),
});
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
aiProvider: "CHATGPT_OAUTH",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
} as never)
.mockResolvedValueOnce({
codexAccessTokenEncrypted: "enc-access",
codexRefreshTokenEncrypted: "enc-refresh",
codexTokenExpiresAt: new Date(Date.now() + 3600000),
codexConnectionStatus: "connected",
} as never);
const provider = await resolveProvider("user-123");
expect(provider.type).toBe("codex");
});
});
/**
* The connection-test "test my saved config" path (empty override body)
* MUST resolve via the SAME chain generation uses, so a green test means
* generation will actually work. Previously it probed codex→admin
* directly, which could test a different provider than generation ran.
*/
describe("resolveProviderForTest — empty override resolves via the chain", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
});
it("returns the first enabled+credentialed chain entry (the same provider generation runs)", async () => {
// resolveProviderForTest first reads the test row (no chain), then
// resolveProviderChain reads the chain row. The chain enables openai
// and the user has an OpenAI key → first chain entry is OpenAIClient.
vi.mocked(prisma.user.findUnique)
// 1st: resolveProviderForTest's own read
.mockResolvedValueOnce({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: "enc-user-openai",
} as never)
// 2nd: resolveProviderChain's read
.mockResolvedValueOnce({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: "enc-user-openai",
aiProviderChain: [
{ providerType: "openai", priority: 1, enabled: true },
],
} as never);
const provider = await resolveProviderForTest("user-123", {});
// The user-OpenAI client is an OpenAIClient; its runtime tag defaults
// to "admin-key" (no providerType passed). What matters is that the
// empty-override path materialised the chain's first entry rather than
// the legacy codex→admin probe — a NoProvider/none would mean the
// chain was bypassed.
expect(provider.type).not.toBe("none");
expect(provider).toBeInstanceOf(OpenAIClient);
});
});
describe("resolveProviderAvailability", () => {
beforeEach(() => {
vi.resetAllMocks();
});
const emptyUserRow = {
aiProvider: null,
aiProviderChain: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiBaseUrl: null,
codexConnectionStatus: null,
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
};
it("returns managedBy:user when the user holds a BYO key", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...emptyUserRow,
aiOpenaiKeyEncrypted: "enc-user-openai",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: null,
} as never);
const result = await resolveProviderAvailability("user-123");
expect(result).toEqual({ aiAvailable: true, managedBy: "user" });
});
it("returns managedBy:server when only an admin-managed key is present", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...emptyUserRow,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin-key",
} as never);
const result = await resolveProviderAvailability("user-123");
expect(result).toEqual({ aiAvailable: true, managedBy: "server" });
});
it("returns managedBy:local when the user set a self-hosted base URL", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...emptyUserRow,
aiProvider: "LOCAL",
aiBaseUrl: "https://ollama.example/v1",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: null,
} as never);
const result = await resolveProviderAvailability("user-123");
expect(result).toEqual({ aiAvailable: true, managedBy: "local" });
});
it("prefers a personal credential over the admin key", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...emptyUserRow,
aiAnthropicKeyEncrypted: "enc-anthropic",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin-key",
} as never);
const result = await resolveProviderAvailability("user-123");
expect(result).toEqual({ aiAvailable: true, managedBy: "user" });
});
it("returns aiAvailable:false + managedBy:null when nothing is configured", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...emptyUserRow,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: null,
} as never);
const result = await resolveProviderAvailability("user-123");
expect(result).toEqual({ aiAvailable: false, managedBy: null });
});
it("returns aiAvailable:false when the user row is missing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
const result = await resolveProviderAvailability("user-123");
expect(result).toEqual({ aiAvailable: false, managedBy: null });
});
});