Files
next-ai-draw-io/tests/unit/validate-model-route.test.ts
T
dayuan.jiang f811242568 refactor(providers): the UI, the connection test and the routes read the record's fields
Step 5 of the provider refactor plan. Provider names gave way to the
record's credential and sdk fields in: hasCredentials (one rule per
credential kind), the connection test (an Object.hasOwn check first, then a
credential switch; EdgeOne's site endpoint and cookie forwarding follow
credential none), the chat route (the on-site function and the single-system
rule for the OpenAI SDK), the credentials form (which fields to show, the
optional-key label, the base URL hint from the record), the picker (the note
from the record), the logo (icon and logo fields), the settings dialog (test
blocker, error hints, EdgeOne origin), the admin panel (fixedCredentials),
chatRequestUrl (sdk), the model list (sdk aihubmix) and the zod schema
(Object.hasOwn instead of 'in'). The eight provider-specific dictionary
entries moved into the record as four-language objects; needAzure became a
generic needUrl.

Two intended behavior changes: the connection test answers 400 'Invalid
provider' for an unknown name whether or not a key is sent (before: 400
'API key is required' without a key, 200 with an error text with one); and
Azure has no placeholder default URL any more, so a new Azure entry starts
with an empty URL and the generic 'needs a URL' rule applies. The e2e
Azure test seeds no URL and expects the new wording. Out-of-layer
provider-name checks: 44 before, 6 after (the admin panel's fixed-variable
branches for Bedrock, Vertex AI and Ollama, kept on purpose). Behavior
snapshot unchanged.
2026-10-12 21:17:51 +09:00

410 lines
14 KiB
TypeScript

// @vitest-environment node
import { streamText } from "ai"
import { afterEach, describe, expect, it, vi } from "vitest"
import { POST as testModel } from "@/app/api/admin/test-model/route"
import { POST as validateModel } from "@/app/api/validate-model/route"
import { getAIModel } from "@/lib/ai-providers"
// No saved admin providers
vi.mock("@/lib/admin/settings", () => ({
loadSettings: () => ({}),
getEnvFallback: (key: string) => process.env[key] ?? null,
}))
// Every URL is public (no DNS in tests), unless a test says otherwise
const privateUrls = vi.hoisted(() => ({ all: false }))
vi.mock("@/lib/ssrf-protection", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ssrf-protection")>()),
isPrivateUrl: async () => privateUrls.all,
}))
// The quota, off unless a test turns it on; every request is refused
const quota = vi.hoisted(() => ({ enabled: false, checks: 0 }))
vi.mock("@/lib/dynamo-quota-manager", () => ({
isQuotaEnabled: () => quota.enabled,
checkAndIncrementRequest: async () => {
quota.checks++
return { allowed: false, error: "Daily limit reached" }
},
}))
vi.mock("@/lib/user-id", () => ({ getUserIdFromRequest: () => "user-1" }))
afterEach(() => {
quota.enabled = false
quota.checks = 0
privateUrls.all = false
vi.unstubAllGlobals()
})
/** An OpenAI-compatible streaming reply made of the given deltas */
function streamReply(...deltas: object[]) {
const chunk = (delta: object, finish: string | null) =>
`data: ${JSON.stringify({
id: "c1",
object: "chat.completion.chunk",
created: 1,
model: "m",
choices: [{ index: 0, delta, finish_reason: finish }],
})}\n\n`
const body =
deltas.map((d) => chunk(d, null)).join("") +
chunk({}, "stop") +
"data: [DONE]\n\n"
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(body, {
headers: { "content-type": "text/event-stream" },
}),
),
)
}
const testGlm = async () => {
const res = await validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "glm",
apiKey: "key",
modelId: "glm-5",
}),
}),
)
return res.json()
}
describe("POST /api/validate-model", () => {
it("passes when the model calls the test tool", async () => {
streamReply({
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "ping", arguments: "{}" },
},
],
})
const data = await testGlm()
expect(data.valid).toBe(true)
expect(data.warning).toBeUndefined()
expect(typeof data.responseTime).toBe("number")
})
it("reports a model that did not answer in time", async () => {
// The 15 s timeout has fired: the SDK ends the stream with an
// abort part instead of throwing
const timedOut = AbortSignal.abort(
new DOMException("The operation timed out.", "TimeoutError"),
)
const timeout = vi
.spyOn(AbortSignal, "timeout")
.mockReturnValue(timedOut)
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw timedOut.reason
}),
)
try {
const data = await testGlm()
expect(data.valid).toBe(false)
expect(data.code).toBe("timeout")
} finally {
timeout.mockRestore()
}
})
it("does not run on the server's keys", async () => {
vi.stubEnv("OLLAMA_API_KEY", "server-ollama-key")
const res = await validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "ollama",
modelId: "any-cloud-model",
}),
}),
)
expect(res.status).toBe(400)
expect((await res.json()).error).toMatch(/API key/)
})
it("warns when the model answers without a tool call", async () => {
streamReply({ role: "assistant", content: "OK" })
const data = await testGlm()
expect(data.valid).toBe(true)
expect(data.warning).toMatch(/without calling a tool/)
})
it("warns when the model calls a tool that does not exist", async () => {
// The SDK passes the call on, marked invalid
streamReply({
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "pong", arguments: "{}" },
},
],
})
const data = await testGlm()
expect(data.valid).toBe(true)
expect(data.warning).toMatch(/without calling a tool/)
})
})
describe("chat requests to a client base URL", () => {
it("refuse redirects when private URLs are blocked", async () => {
vi.stubEnv("ALLOW_PRIVATE_URLS", "false")
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/" },
}),
),
)
const { model } = getAIModel({
provider: "glm",
apiKey: "key",
baseUrl: "https://attacker.example/v1",
modelId: "glm-5",
})
let error: unknown
const result = streamText({
model,
prompt: "hi",
maxRetries: 0,
onError: ({ error: e }) => {
error = e
},
})
await result.consumeStream()
expect(String(error)).toMatch(/Redirects are not allowed/)
})
})
describe("testing EdgeOne", () => {
// The request validate-model sends to the EdgeOne function
const capture = () => {
const calls: Array<{ url: string; headers: Headers }> = []
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
url: String(url),
headers: new Headers(init?.headers),
})
throw new Error("no network in tests")
}),
)
return calls
}
it("calls the site's own function, also without a base URL", async () => {
// The admin panel's Test sends none; a relative one cannot be fetched
const calls = capture()
await validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
origin: "https://draw.example",
},
body: JSON.stringify({
provider: "edgeone",
modelId: "@tx/deepseek-ai/deepseek-v3-0324",
baseUrl: "https://elsewhere.example/api/edgeai",
}),
}),
)
expect(calls[0]?.url).toBe(
"https://draw.example/api/edgeai/chat/completions",
)
})
it("passes the admin's access code and cookies on", async () => {
// The EdgeOne function checks the access code too
vi.stubEnv("ADMIN_PASSWORD", "admin-pw")
const calls = capture()
await testModel(
new Request("http://localhost/api/admin/test-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-admin-password": "admin-pw",
"x-access-code": "visitor-code",
cookie: "eo_token=t",
origin: "https://draw.example",
},
body: JSON.stringify({
provider: {
id: "p1",
provider: "edgeone",
models: ["@tx/deepseek-ai/deepseek-v3-0324"],
},
modelId: "@tx/deepseek-ai/deepseek-v3-0324",
}),
}),
)
expect(calls[0]?.url).toBe(
"https://draw.example/api/edgeai/chat/completions",
)
expect(calls[0]?.headers.get("x-access-code")).toBe("visitor-code")
expect(calls[0]?.headers.get("cookie")).toBe("eo_token=t")
})
})
describe("the admin Test of the server's own base URL", () => {
// An admin panel entry without a URL of its own
const adminTest = () =>
testModel(
new Request("http://localhost/api/admin/test-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-admin-password": "admin-pw",
},
body: JSON.stringify({
provider: {
id: "p1",
provider: "openai",
apiKey: "panel-key",
models: ["gpt-5.5"],
},
modelId: "gpt-5.5",
}),
}),
)
// A user naming the same address in the Settings dialog
const userTest = () =>
validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "openai",
apiKey: "panel-key",
modelId: "gpt-5.5",
baseUrl: "http://10.0.0.5:8000/v1",
}),
}),
)
it("tests it as chat uses it: an internal address is allowed", async () => {
// ALLOW_PRIVATE_URLS=false guards URLs users type, not the server's
vi.stubEnv("ALLOW_PRIVATE_URLS", "false")
vi.stubEnv("OPENAI_BASE_URL", "http://10.0.0.5:8000/v1")
vi.stubEnv("ADMIN_PASSWORD", "admin-pw")
privateUrls.all = true
streamReply({ role: "assistant", content: "OK" })
const admin = await (await adminTest()).json()
expect(admin.valid).toBe(true)
// A user typing the same address is still refused
const other = await userTest()
expect(other.status).toBe(400)
})
})
describe("the admin panel's Test button", () => {
it("works when access codes are set", async () => {
// The admin password stands in for the visitor access code
vi.stubEnv("ACCESS_CODE_LIST", "visitor-code")
vi.stubEnv("ADMIN_PASSWORD", "admin-pw")
streamReply({ role: "assistant", content: "OK" })
const res = await testModel(
new Request("http://localhost/api/admin/test-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-admin-password": "admin-pw",
},
body: JSON.stringify({
provider: {
id: "p1",
provider: "glm",
apiKey: "key",
models: ["glm-5"],
},
modelId: "glm-5",
}),
}),
)
expect(res.status).toBe(200)
expect((await res.json()).valid).toBe(true)
})
})
describe("the Test on the deployment's own endpoints", () => {
const test = (body: object) =>
validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelId: "m", ...body }),
}),
)
it("counts as a chat request with the quota on", async () => {
quota.enabled = true
// EdgeOne, and a model server on the server's network with a dummy key
const edgeone = await test({ provider: "edgeone" })
expect(edgeone.status).toBe(429)
privateUrls.all = true
const internal = await test({
provider: "openai",
apiKey: "x",
baseUrl: "http://10.0.0.5:8000/v1",
})
expect(internal.status).toBe(429)
expect(quota.checks).toBe(2)
})
it("does not count a user's own endpoint", async () => {
quota.enabled = true
streamReply({ role: "assistant", content: "OK" })
const res = await test({
provider: "openai",
apiKey: "user-key",
baseUrl: "https://api.example.com/v1",
})
expect(res.status).toBe(200)
expect(quota.checks).toBe(0)
})
})
describe("POST /api/validate-model with an unknown provider", () => {
const send = (body: object) =>
validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelId: "m", ...body }),
}),
)
it("refuses a name that is not a provider, with or without a key", async () => {
for (const body of [
{ provider: "nope", apiKey: "k" },
{ provider: "nope" },
// A prototype property is not a provider either
{ provider: "toString", apiKey: "k" },
]) {
const res = await send(body)
expect(res.status).toBe(400)
const { error } = await res.json()
expect(error).toMatch(/^Invalid provider: /)
}
})
})