mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
- Reflect built-in defaults for boolean settings (ALLOW_PRIVATE_URLS defaults on) and allow clearing a saved boolean back to default, so the SSRF toggle matches actual runtime behavior. - Harden JSON loading: filter settings values to strings only, and schema-validate stored ADMIN_PROVIDERS entries, dropping malformed ones instead of letting them reach runtime code. - Set beforeunload returnValue so the unsaved-changes prompt shows in all browsers; reject non-finite numbers in settings validation. - Fix README/CN/JA docs that claimed the panel auto-generates AI_MODELS_CONFIG (providers are merged at read time, not written). - Add unit tests for corrupted-file value filtering and provider schema validation.
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import fs from "fs"
|
|
import os from "os"
|
|
import path from "path"
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
import {
|
|
ADMIN_PROVIDERS_KEY,
|
|
adminProvidersToConfig,
|
|
deriveEnvUpdates,
|
|
loadAdminProviders,
|
|
mergeSecrets,
|
|
type StoredAdminProvider,
|
|
validateAdminProviders,
|
|
} from "@/lib/admin/providers"
|
|
import { _resetForTests, saveSettings } from "@/lib/admin/settings"
|
|
import { loadRawServerModelsConfig } from "@/lib/server-model-config"
|
|
|
|
let tmpDir: string
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-"))
|
|
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
|
|
_resetForTests()
|
|
})
|
|
|
|
afterEach(() => {
|
|
_resetForTests()
|
|
delete process.env.SETTINGS_FILE
|
|
delete process.env.AI_MODELS_CONFIG
|
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function provider(
|
|
overrides: Partial<StoredAdminProvider> = {},
|
|
): StoredAdminProvider {
|
|
return {
|
|
id: "p1",
|
|
provider: "openai",
|
|
apiKey: "sk-test",
|
|
models: ["gpt-5.2"],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe("deriveEnvUpdates", () => {
|
|
it("writes credentials to ADMIN_-prefixed env vars (never shadows standard vars)", () => {
|
|
const updates = deriveEnvUpdates([provider()], [])
|
|
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
|
expect(updates.OPENAI_API_KEY).toBeUndefined()
|
|
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
|
|
// AI_MODELS_CONFIG is no longer written (merged at read time)
|
|
expect(updates.AI_MODELS_CONFIG).toBeNull()
|
|
})
|
|
|
|
it("suffixes env vars for a second instance of the same provider", () => {
|
|
const updates = deriveEnvUpdates(
|
|
[
|
|
provider({ id: "p1", name: "First" }),
|
|
provider({
|
|
id: "p2",
|
|
name: "Second",
|
|
apiKey: "sk-second",
|
|
models: ["gpt-5-mini"],
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
|
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
|
|
})
|
|
|
|
it("maps bedrock credentials to AWS env vars", () => {
|
|
const updates = deriveEnvUpdates(
|
|
[
|
|
provider({
|
|
provider: "bedrock",
|
|
apiKey: undefined,
|
|
awsAccessKeyId: "AKIA123",
|
|
awsSecretAccessKey: "secret",
|
|
awsRegion: "us-west-2",
|
|
models: ["claude-x"],
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(updates.AWS_ACCESS_KEY_ID).toBe("AKIA123")
|
|
expect(updates.AWS_SECRET_ACCESS_KEY).toBe("secret")
|
|
expect(updates.AWS_REGION).toBe("us-west-2")
|
|
})
|
|
|
|
it("clears keys owned by the previous list when providers are removed", () => {
|
|
const prev = [provider()]
|
|
const updates = deriveEnvUpdates([], prev)
|
|
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
|
|
expect(updates.AI_MODELS_CONFIG).toBeNull()
|
|
expect(updates.ADMIN_PROVIDERS).toBeNull()
|
|
})
|
|
|
|
it("sets AI_PROVIDER/AI_MODEL only when a default is flagged", () => {
|
|
const noDefault = deriveEnvUpdates([provider()], [])
|
|
expect(noDefault.AI_PROVIDER).toBeNull()
|
|
expect(noDefault.AI_MODEL).toBeNull()
|
|
|
|
const updates = deriveEnvUpdates(
|
|
[
|
|
provider({ id: "p1" }),
|
|
provider({
|
|
id: "p2",
|
|
provider: "deepseek",
|
|
models: ["deepseek-chat"],
|
|
isDefault: true,
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(updates.AI_PROVIDER).toBe("deepseek")
|
|
expect(updates.AI_MODEL).toBe("deepseek-chat")
|
|
})
|
|
})
|
|
|
|
describe("adminProvidersToConfig", () => {
|
|
it("builds a config with ADMIN_-prefixed apiKeyEnv wiring", () => {
|
|
const config = adminProvidersToConfig([provider()])
|
|
expect(config.providers).toHaveLength(1)
|
|
expect(config.providers[0].models).toEqual(["gpt-5.2"])
|
|
expect(config.providers[0].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY")
|
|
})
|
|
|
|
it("wires suffixed env vars for a second instance", () => {
|
|
const config = adminProvidersToConfig([
|
|
provider({ id: "p1", name: "First" }),
|
|
provider({
|
|
id: "p2",
|
|
name: "Second",
|
|
apiKey: "sk-second",
|
|
models: ["gpt-5-mini"],
|
|
}),
|
|
])
|
|
expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2")
|
|
})
|
|
|
|
it("skips providers without models and carries the default flag", () => {
|
|
const config = adminProvidersToConfig([
|
|
provider({ id: "p1", models: [] }),
|
|
provider({ id: "p2", name: "D", isDefault: true }),
|
|
])
|
|
expect(config.providers).toHaveLength(1)
|
|
expect(config.providers[0].default).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("mergeSecrets", () => {
|
|
it("keeps stored secret when client sends an isSet marker", () => {
|
|
const stored = [provider({ apiKey: "sk-original" })]
|
|
const merged = mergeSecrets(
|
|
[
|
|
{
|
|
...provider(),
|
|
apiKey: { isSet: true, hint: "…test" },
|
|
},
|
|
],
|
|
stored,
|
|
)
|
|
expect(merged[0].apiKey).toBe("sk-original")
|
|
})
|
|
|
|
it("replaces secret when client sends a plaintext string", () => {
|
|
const stored = [provider({ apiKey: "sk-original" })]
|
|
const merged = mergeSecrets(
|
|
[{ ...provider(), apiKey: "sk-new" }],
|
|
stored,
|
|
)
|
|
expect(merged[0].apiKey).toBe("sk-new")
|
|
})
|
|
|
|
it("clears secret when client sends undefined", () => {
|
|
const stored = [provider({ apiKey: "sk-original" })]
|
|
const merged = mergeSecrets(
|
|
[{ ...provider(), apiKey: undefined }],
|
|
stored,
|
|
)
|
|
expect(merged[0].apiKey).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("loadRawServerModelsConfig merge", () => {
|
|
it("combines env AI_MODELS_CONFIG with panel providers", async () => {
|
|
process.env.AI_MODELS_CONFIG = JSON.stringify({
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-from-env"],
|
|
default: true,
|
|
},
|
|
],
|
|
})
|
|
saveSettings(deriveEnvUpdates([provider({ name: "Panel" })], []))
|
|
|
|
const merged = await loadRawServerModelsConfig()
|
|
expect(merged?.providers.map((p) => p.name)).toEqual([
|
|
"Env OpenAI",
|
|
"Panel",
|
|
])
|
|
// Env default kept because panel set none
|
|
expect(merged?.providers[0].default).toBe(true)
|
|
})
|
|
|
|
it("panel default overrides the env default", async () => {
|
|
process.env.AI_MODELS_CONFIG = JSON.stringify({
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-from-env"],
|
|
default: true,
|
|
},
|
|
],
|
|
})
|
|
saveSettings(
|
|
deriveEnvUpdates(
|
|
[provider({ name: "Panel", isDefault: true })],
|
|
[],
|
|
),
|
|
)
|
|
|
|
const merged = await loadRawServerModelsConfig()
|
|
expect(merged?.providers[0].default).toBeFalsy()
|
|
expect(merged?.providers[1].default).toBe(true)
|
|
})
|
|
|
|
it("returns only env config when the panel has no providers", async () => {
|
|
process.env.AI_MODELS_CONFIG = JSON.stringify({
|
|
providers: [
|
|
{
|
|
name: "Env Only",
|
|
provider: "openai",
|
|
models: ["gpt-from-env"],
|
|
},
|
|
],
|
|
})
|
|
const merged = await loadRawServerModelsConfig()
|
|
expect(merged?.providers.map((p) => p.name)).toEqual(["Env Only"])
|
|
})
|
|
})
|
|
|
|
describe("validateAdminProviders", () => {
|
|
it("rejects names clashing with env-configured providers", () => {
|
|
expect(
|
|
validateAdminProviders([provider({ name: "Env OpenAI" })], {
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-x"],
|
|
},
|
|
],
|
|
}),
|
|
).toMatch(/already defined/)
|
|
})
|
|
|
|
it("rejects a global-credential provider already in the env config", () => {
|
|
expect(
|
|
validateAdminProviders(
|
|
[
|
|
provider({
|
|
provider: "bedrock",
|
|
apiKey: undefined,
|
|
awsAccessKeyId: "AKIA-panel",
|
|
awsSecretAccessKey: "panel-secret",
|
|
awsRegion: "us-east-1",
|
|
models: ["claude-x"],
|
|
}),
|
|
],
|
|
{
|
|
providers: [
|
|
{
|
|
name: "Env Bedrock",
|
|
provider: "bedrock",
|
|
models: ["claude-env"],
|
|
},
|
|
],
|
|
},
|
|
),
|
|
).toMatch(/shares global credentials/)
|
|
})
|
|
|
|
it("allows a normal provider type alongside the same env type", () => {
|
|
expect(
|
|
validateAdminProviders([provider({ name: "Panel OpenAI" })], {
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-x"],
|
|
},
|
|
],
|
|
}),
|
|
).toBeNull()
|
|
})
|
|
|
|
it("rejects two bedrock instances", () => {
|
|
const list = [
|
|
provider({ id: "p1", provider: "bedrock" }),
|
|
provider({ id: "p2", provider: "bedrock" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/Only one/)
|
|
})
|
|
|
|
it("rejects duplicate display names", () => {
|
|
const list = [
|
|
provider({ id: "p1", name: "Same" }),
|
|
provider({ id: "p2", name: "Same" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/unique/)
|
|
})
|
|
|
|
it("rejects multiple defaults", () => {
|
|
const list = [
|
|
provider({ id: "p1", isDefault: true }),
|
|
provider({ id: "p2", name: "Other", isDefault: true }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/default/)
|
|
})
|
|
|
|
it("accepts a valid list", () => {
|
|
const list = [
|
|
provider({ id: "p1", isDefault: true }),
|
|
provider({ id: "p2", name: "Backup" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("loadAdminProviders", () => {
|
|
it("returns [] when nothing is stored", () => {
|
|
expect(loadAdminProviders()).toEqual([])
|
|
})
|
|
|
|
it("loads valid stored providers", () => {
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([provider()]) })
|
|
expect(loadAdminProviders()).toHaveLength(1)
|
|
})
|
|
|
|
it("drops malformed entries and keeps valid ones", () => {
|
|
saveSettings({
|
|
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
|
provider({ id: "good" }),
|
|
{ id: "missing-fields" }, // no provider/models
|
|
{ provider: "openai", models: ["x"] }, // no id
|
|
"not-an-object",
|
|
]),
|
|
})
|
|
const loaded = loadAdminProviders()
|
|
expect(loaded).toHaveLength(1)
|
|
expect(loaded[0].id).toBe("good")
|
|
})
|
|
|
|
it("returns [] when the stored value is not an array", () => {
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify({ nope: true }) })
|
|
expect(loadAdminProviders()).toEqual([])
|
|
})
|
|
|
|
it("returns [] on invalid JSON", () => {
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" })
|
|
expect(loadAdminProviders()).toEqual([])
|
|
})
|
|
})
|