mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 02:37:46 +08:00
- A redirect followed for a custom base URL also drops the key headers of providers that do not use Authorization (x-api-key, x-goog-api-key, api-key) when it goes to another origin. - A second Enter or click while a message is being prepared (attachments read, diagram exported) no longer sends it twice. - The admin Test on the deployment's own endpoints (EdgeOne, the server's keyless Ollama, an address on the server's network) counts toward the quota like a chat; the chat and the Test share one rule for it. The Test of an Azure entry set up by AZURE_RESOURCE_NAME only goes where chat goes. - EdgeOne's function is called at the site root again, as on main: EdgeOne serves edge functions there, outside Next's base path. - MCP History: the state before a write is kept unless the browser saved no change of the user's since the last server write (draw.io's sync copy of it adds no entry), and the dedupe compares the exact text again, so a change of page size or other settings only is its own version. - MCP: an edit keeps untouched labels as draw.io shows them (a literal line break in an attribute is a space); a new document of empty pages the user named is auto-saved; load_diagram reads only regular files, so a pipe cannot hold up the other write tools; the preview does not load back its own push still on its way (an undo made meanwhile is saved). - Two overlapping saves of a new chat no longer reload the canvas from the older copy. - At most three screenshot checks per user turn, passed or failed, as documented. - Desktop: the main window navigates only within the app (draw.io stays in its frame); a presets file that is not JSON and cannot be moved aside is not overwritten. - A last self-closing cell with a raw "<" in a value is not taken for cut off output. - README: Material Design shapes load their icons from fonts.gstatic.com.
369 lines
13 KiB
TypeScript
369 lines
13 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(() => {
|
|
delete process.env.ALLOW_PRIVATE_URLS
|
|
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 () => {
|
|
process.env.OLLAMA_API_KEY = "server-ollama-key"
|
|
try {
|
|
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/)
|
|
} finally {
|
|
delete process.env.OLLAMA_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/)
|
|
})
|
|
})
|
|
|
|
describe("chat requests to a client base URL", () => {
|
|
it("refuse redirects when private URLs are blocked", async () => {
|
|
process.env.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
|
|
process.env.ADMIN_PASSWORD = "admin-pw"
|
|
try {
|
|
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")
|
|
} finally {
|
|
delete process.env.ADMIN_PASSWORD
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("the admin Test of the server's own base URL", () => {
|
|
const test = (headers: Record<string, string>) =>
|
|
validateModel(
|
|
new Request("http://localhost/api/validate-model", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify({
|
|
provider: "openai",
|
|
apiKey: "panel-key",
|
|
modelId: "gpt-5.5",
|
|
baseUrl: "http://10.0.0.5:8000/v1",
|
|
serverBaseUrl: true,
|
|
}),
|
|
}),
|
|
)
|
|
|
|
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
|
|
process.env.ALLOW_PRIVATE_URLS = "false"
|
|
process.env.OPENAI_BASE_URL = "http://10.0.0.5:8000/v1"
|
|
process.env.ADMIN_PASSWORD = "admin-pw"
|
|
privateUrls.all = true
|
|
try {
|
|
streamReply({ role: "assistant", content: "OK" })
|
|
const admin = await (
|
|
await test({ "x-admin-password": "admin-pw" })
|
|
).json()
|
|
expect(admin.valid).toBe(true)
|
|
// Anyone else claiming it is still refused
|
|
const other = await test({})
|
|
expect(other.status).toBe(400)
|
|
} finally {
|
|
privateUrls.all = false
|
|
delete process.env.OPENAI_BASE_URL
|
|
delete process.env.ADMIN_PASSWORD
|
|
}
|
|
})
|
|
})
|
|
|
|
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
|
|
process.env.ACCESS_CODE_LIST = "visitor-code"
|
|
process.env.ADMIN_PASSWORD = "admin-pw"
|
|
try {
|
|
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)
|
|
} finally {
|
|
delete process.env.ACCESS_CODE_LIST
|
|
delete process.env.ADMIN_PASSWORD
|
|
}
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|