/** * Org agent-config API tests: provider connection, models, roles (ADR-0017 / * ADR-0021). Also covers the DB-backed runtime registry wiring. */ import Fastify from "fastify"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { registerAdminPlugin } from "../../src/admin/plugin.js"; import { mintSessionToken, sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; import { createDbRuntimeSettings } from "../../src/settings/runtime.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; const SESSION_SECRET = "integration-test-session-secret"; async function buildApp() { const app = Fastify({ logger: false }); await registerAdminPlugin(app, { prisma, sessionSecret: SESSION_SECRET, publicBaseUrl: "http://127.0.0.1:8788", feishuAppId: "cli_test", feishuAppSecret: "secret_test", projectWorkspaceRoot: "/tmp/cph-test-ws", cookieSecure: false, }); await app.ready(); return app; } async function seedOwner(): Promise { await prisma.user.create({ data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" }, }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" }, }); return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET); } describe("admin agent-config API (provider/models/roles)", () => { beforeEach(async () => { await resetDb(); }); afterAll(async () => { await prisma.$disconnect(); }); it("upserts a BYOK provider connection and reads it back without exposing the token", async () => { const token = await seedOwner(); const app = await buildApp(); try { const cookie = sessionCookieHeader(token); const put = await app.inject({ method: "PUT", url: "/api/org/test-default/provider-connection", headers: { cookie, "content-type": "application/json" }, payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "secret-token-123" }, }); expect(put.statusCode).toBe(200); const conn = put.json() as { mode: string; baseUrl: string; hasAuthToken: boolean }; expect(conn.mode).toBe("BYOK"); expect(conn.baseUrl).toBe("https://gw.test/api"); expect(conn.hasAuthToken).toBe(true); const get = await app.inject({ method: "GET", url: "/api/org/test-default/provider-connection", headers: { cookie }, }); const got = get.json() as { mode: string; hasAuthToken: boolean }; expect(got.mode).toBe("BYOK"); expect(got.hasAuthToken).toBe(true); // Token value must never be returned by GET. expect(get.body).not.toContain("secret-token-123"); } finally { await app.close(); } }); it("creates, updates and deletes an enabled model", async () => { const token = await seedOwner(); const app = await buildApp(); try { const cookie = sessionCookieHeader(token); const create = await app.inject({ method: "POST", url: "/api/org/test-default/models", headers: { cookie, "content-type": "application/json" }, payload: { modelId: "anthropic/claude-sonnet-5", label: "Sonnet 5" }, }); expect(create.statusCode).toBe(201); const model = create.json() as { id: string; modelId: string }; expect(model.modelId).toBe("anthropic/claude-sonnet-5"); // Duplicate modelId is rejected. const dup = await app.inject({ method: "POST", url: "/api/org/test-default/models", headers: { cookie, "content-type": "application/json" }, payload: { modelId: "anthropic/claude-sonnet-5", label: "dup" }, }); expect(dup.statusCode).toBe(400); const upd = await app.inject({ method: "PATCH", url: `/api/org/test-default/models/${model.id}`, headers: { cookie, "content-type": "application/json" }, payload: { label: "Sonnet 5 (tool)", toolCapable: true, sortKey: "10" }, }); expect(upd.statusCode).toBe(200); expect((upd.json() as { label: string }).label).toBe("Sonnet 5 (tool)"); const list = await app.inject({ method: "GET", url: "/api/org/test-default/models", headers: { cookie }, }); expect((list.json() as { models: unknown[] }).models).toHaveLength(1); const del = await app.inject({ method: "DELETE", url: `/api/org/test-default/models/${model.id}`, headers: { cookie }, }); expect(del.statusCode).toBe(200); const after = await app.inject({ method: "GET", url: "/api/org/test-default/models", headers: { cookie }, }); expect((after.json() as { models: unknown[] }).models).toHaveLength(0); } finally { await app.close(); } }); it("creates a role with tools whitelist and rejects unknown tool ids", async () => { const token = await seedOwner(); const app = await buildApp(); try { const cookie = sessionCookieHeader(token); const create = await app.inject({ method: "POST", url: "/api/org/test-default/roles", headers: { cookie, "content-type": "application/json" }, payload: { roleId: "draft", label: "草稿", systemPrompt: "You are a teaching drafter.", tools: ["read_file", "write_file", "cph_check"], }, }); expect(create.statusCode).toBe(201); const role = create.json() as { id: string; roleId: string; tools: string[] }; expect(role.roleId).toBe("draft"); expect(role.tools).toEqual(["read_file", "write_file", "cph_check"]); // Unknown tool id is rejected. const bad = await app.inject({ method: "POST", url: "/api/org/test-default/roles", headers: { cookie, "content-type": "application/json" }, payload: { roleId: "bad", label: "bad", tools: ["not_a_real_tool"] }, }); expect(bad.statusCode).toBe(500); // null tools ⇒ unrestricted. const unrestricted = await app.inject({ method: "PATCH", url: `/api/org/test-default/roles/${role.id}`, headers: { cookie, "content-type": "application/json" }, payload: { tools: null }, }); expect(unrestricted.statusCode).toBe(200); expect((unrestricted.json() as { tools: string[] | null }).tools).toBeNull(); } finally { await app.close(); } }); it("DbRuntimeSettings reads org-scoped models/roles and falls back to env", async () => { const token = await seedOwner(); const app = await buildApp(); try { const cookie = sessionCookieHeader(token); await app.inject({ method: "POST", url: "/api/org/test-default/models", headers: { cookie, "content-type": "application/json" }, payload: { modelId: "z-ai/glm-4.7", label: "GLM", toolCapable: true }, }); await app.inject({ method: "POST", url: "/api/org/test-default/roles", headers: { cookie, "content-type": "application/json" }, payload: { roleId: "draft", label: "草稿", defaultModelId: "z-ai/glm-4.7" }, }); await prisma.project.create({ data: { id: "p-cfg", organizationId: DEFAULT_ORG_ID, name: "CfgProj", workspaceDir: "/tmp/p-cfg" }, }); const settings = createDbRuntimeSettings(prisma, { ANTHROPIC_AUTH_TOKEN: "env-token", ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5", }); const registry = await settings.modelRegistry({ projectId: "p-cfg" }); const models = registry.listModels(); expect(models.map((m) => m.id)).toContain("z-ai/glm-4.7"); const draft = registry.role("draft"); expect(draft?.label).toBe("草稿"); expect(registry.resolve(undefined, "draft")).toBe("z-ai/glm-4.7"); // Unknown project ⇒ env fallback registry. const fallback = await settings.modelRegistry({ projectId: "no-such-project" }); expect(fallback.listModels().map((m) => m.id)).toContain("anthropic/claude-sonnet-5"); // BYOK provider connection resolves org credentials. await app.inject({ method: "PUT", url: "/api/org/test-default/provider-connection", headers: { cookie, "content-type": "application/json" }, payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "byok-token" }, }); const provider = await settings.provider("openrouter", { projectId: "p-cfg" }); expect(provider.baseUrl).toBe("https://gw.test/api"); expect(provider.authToken).toBe("byok-token"); } finally { await app.close(); } }); });