Files
sub2api/frontend/src/views/admin/__tests__/SettingsView.spec.ts
T
shaw fead4c7ec3 feat(security): add panel API rate limiting to protect DB from high-frequency requests
用户可高频刷面板接口(usage/dashboard 等重聚合查询)直接打爆数据库:
现有限流器只覆盖登录/注册等公开认证入口,登录后的全部面板端点无任何限流。

三层防护(阈值均可在后台可视化配置,panel_rate_limit_settings):

1. 认证面板接口按「用户 ID」限流,与来源 IP 无关——反向代理/NAT 共享出口
   (所有请求源地址坍缩为 127.0.0.1 等)不会互相误伤:
   - Global 档(默认 240 rpm/账号):user/auth/payment/admin 全部登录后路由
   - Heavy 档(默认 60 rpm/账号):/usage、/usage/dashboard/*、
     /user/api-keys/:id/usage/daily 等重 SQL 聚合端点叠加计数
   - 管理员默认豁免(可关闭)

2. 无认证公开接口(/api/v1/settings/*,每次请求都查 DB)按安全客户端 IP
   限流(默认 300 rpm/IP);回环/私网/链路本地地址(反代内部转发地址)
   一律跳过计数,杜绝把整条反代链路合并进同一个桶造成大面积误拦截。

3. 修复既有隐患:auth 入口限流的 IP 取值从 c.ClientIP() 切换到与审计日志/
   会话绑定/API Key ACL 同源的安全客户端 IP 解析(尊重后台「信任反代转发
   IP」开关快照)。原实现下默认反代部署(未配置 server.trusted_proxies)
   所有用户共享同一个登录限流桶,既会全员误拦也可被单人恶意占满形成登录
   DoS;开关关闭时行为与原来完全一致。

工程约束:
- 配置热路径走进程内缓存(atomic.Value + singleflight,60s TTL),
  限流中间件零 DB 访问;保存后当前节点立即生效
- 面板限流 Redis 故障 fail-open(auth 入口保持原有 fail-close)
- 429 响应携带 Retry-After;错误码 RATE_LIMITED
- 支付 webhook / 公开支付回调有意不挂限流
- 新增 GET/PUT /api/v1/admin/settings/panel-rate-limit;设置页安全 tab
  新增「面板接口限流」卡片(zh/en i18n 全量)

测试:rate_limiter/panel_rate_limit/setting_panel_rate_limit 单测全绿;
routes、handler/admin、-tags unit 契约测试通过;前端 vue-tsc/ESLint/
SettingsView spec(26/26,含新增交互用例)/i18n 守卫全部通过。
2026-07-27 15:12:51 +08:00

1616 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import SettingsView from "../SettingsView.vue";
const {
getSettings,
updateSettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig,
getAdminApiKey,
getOverloadCooldownSettings,
getRateLimit429CooldownSettings,
updateRateLimit429CooldownSettings,
getPanelRateLimitSettings,
updatePanelRateLimitSettings,
getStreamTimeoutSettings,
getRectifierSettings,
getBetaPolicySettings,
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
getOllamaCloudUsageSettings,
updateOllamaCloudUsageSettings,
getGroups,
listProxies,
getProviders,
updateProvider,
createProvider,
deleteProvider,
fetchPublicSettings,
adminSettingsFetch,
showError,
showSuccess,
} = vi.hoisted(() => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
getWebSearchEmulationConfig: vi.fn(),
updateWebSearchEmulationConfig: vi.fn(),
getAdminApiKey: vi.fn(),
getOverloadCooldownSettings: vi.fn(),
getRateLimit429CooldownSettings: vi.fn(),
updateRateLimit429CooldownSettings: vi.fn(),
getPanelRateLimitSettings: vi.fn().mockResolvedValue({
enabled: true,
user_rpm: 240,
heavy_rpm: 60,
exempt_admin: true,
public_ip_rpm: 300,
}),
updatePanelRateLimitSettings: vi.fn().mockImplementation(async (payload) => payload),
getStreamTimeoutSettings: vi.fn(),
getRectifierSettings: vi.fn(),
getBetaPolicySettings: vi.fn(),
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({
enabled: true,
interval_minutes: 30,
}),
updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload),
getOllamaCloudUsageSettings: vi.fn().mockResolvedValue({
enabled: false,
interval_minutes: 60,
debounce_minutes: 1,
}),
updateOllamaCloudUsageSettings: vi.fn().mockImplementation(async (payload) => payload),
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
updateProvider: vi.fn(),
createProvider: vi.fn(),
deleteProvider: vi.fn(),
fetchPublicSettings: vi.fn(),
adminSettingsFetch: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}));
const localeRef = vi.hoisted(() => ({ value: "zh-CN" }));
vi.mock("@/api", () => ({
adminAPI: {
settings: {
getSettings,
updateSettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig,
getAdminApiKey,
getOverloadCooldownSettings,
getRateLimit429CooldownSettings,
updateRateLimit429CooldownSettings,
getPanelRateLimitSettings,
updatePanelRateLimitSettings,
getStreamTimeoutSettings,
getRectifierSettings,
getBetaPolicySettings,
},
accounts: {
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
getOllamaCloudUsageSettings,
updateOllamaCloudUsageSettings,
},
groups: {
getAll: getGroups,
},
proxies: {
list: listProxies,
},
payment: {
getProviders,
updateProvider,
createProvider,
deleteProvider,
},
},
}));
vi.mock("@/stores", () => ({
useAppStore: () => ({
showError,
showSuccess,
showWarning: vi.fn(),
showInfo: vi.fn(),
fetchPublicSettings,
}),
}));
vi.mock("@/stores/adminSettings", () => ({
useAdminSettingsStore: () => ({
fetch: adminSettingsFetch,
}),
}));
vi.mock("@/composables/useClipboard", () => ({
useClipboard: () => ({
copyToClipboard: vi.fn(),
}),
}));
vi.mock("@/utils/apiError", () => ({
extractApiErrorMessage: () => "error",
}));
vi.mock("vue-i18n", async () => {
const actual = await vi.importActual<typeof import("vue-i18n")>("vue-i18n");
const translations: Record<string, string> = {
"admin.settings.wechatConnect.title": "微信登录",
"admin.settings.wechatConnect.description": "用于微信开放平台或公众号/小程序的第三方登录配置。",
"admin.settings.wechatConnect.enabledLabel": "启用微信登录",
"admin.settings.wechatConnect.enabledHint": "开启后可使用微信第三方登录回调与授权配置。",
"admin.settings.wechatConnect.appIdLabel": "AppID",
"admin.settings.wechatConnect.appIdPlaceholder": "微信开放平台 AppID",
"admin.settings.wechatConnect.appSecretLabel": "AppSecret",
"admin.settings.wechatConnect.appSecretConfiguredPlaceholder": "密钥已配置,留空以保留当前值。",
"admin.settings.wechatConnect.appSecretPlaceholder": "微信开放平台 AppSecret",
"admin.settings.wechatConnect.appSecretConfiguredHint": "密钥已配置,留空以保留当前值。",
"admin.settings.wechatConnect.appSecretHint": "填写后会覆盖当前微信密钥。",
"admin.settings.wechatConnect.modeLabel": "模式",
"admin.settings.wechatConnect.openModeLabel": "非微信环境使用开放平台",
"admin.settings.wechatConnect.openModeHint": "浏览器不在微信内时,自动走开放平台扫码授权。",
"admin.settings.wechatConnect.mpModeLabel": "微信环境使用公众号",
"admin.settings.wechatConnect.mpModeHint": "浏览器在微信内时,自动走公众号授权。",
"admin.settings.wechatConnect.redirectUrlLabel": "回调地址",
"admin.settings.wechatConnect.redirectUrlPlaceholder": "https://your-site.com/api/v1/auth/oauth/wechat/callback",
"admin.settings.wechatConnect.generateAndCopy": "使用当前站点生成并复制",
"admin.settings.wechatConnect.redirectUrlSetAndCopied": "已使用当前站点生成回调地址并复制到剪贴板",
"admin.settings.wechatConnect.frontendRedirectUrlLabel": "前端回调地址",
"admin.settings.wechatConnect.frontendRedirectUrlPlaceholder": "/auth/wechat/callback",
"admin.settings.wechatConnect.frontendRedirectUrlHint": "通常用于前端路由回调地址,需与后端配置保持一致。",
"admin.settings.authSourceDefaults.title": "认证来源默认值",
"admin.settings.authSourceDefaults.description": "按注册来源配置新用户默认余额、并发、订阅与授权策略。",
"admin.settings.authSourceDefaults.requireEmailLabel": "第三方注册强制补充邮箱",
"admin.settings.authSourceDefaults.requireEmailHint": "启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。",
"admin.settings.authSourceDefaults.enabledHint": "以下默认值会在该来源注册新用户时发放;首次绑定时授权仅作用于已有账号绑定该来源。",
"admin.settings.authSourceDefaults.sources.email.title": "邮箱注册",
"admin.settings.authSourceDefaults.sources.email.description": "适用于邮箱密码注册的新用户默认配额。",
"admin.settings.authSourceDefaults.sources.linuxdo.title": "Linux DO 登录",
"admin.settings.authSourceDefaults.sources.linuxdo.description": "适用于 Linux DO 第三方注册的新用户默认配额。",
"admin.settings.authSourceDefaults.sources.oidc.title": "OIDC 登录",
"admin.settings.authSourceDefaults.sources.oidc.description": "适用于 OIDC 第三方注册的新用户默认配额。",
"admin.settings.authSourceDefaults.sources.wechat.title": "微信登录",
"admin.settings.authSourceDefaults.sources.wechat.description": "适用于微信第三方注册的新用户默认配额。",
"admin.settings.authSourceDefaults.grantOnFirstBindLabel": "首次绑定时授权",
"admin.settings.authSourceDefaults.grantOnFirstBindHint": "已有账号首次绑定该来源时发放默认权益。",
"admin.settings.authSourceDefaults.defaultSubscriptionsLabel": "默认订阅",
"admin.settings.authSourceDefaults.defaultSubscriptionsHint": "仅对当前认证来源生效,未配置时不追加来源专属订阅。",
"admin.settings.authSourceDefaults.noSourceSubscriptions": "当前来源未配置专属默认订阅。",
"admin.settings.paymentVisibleMethods.methodLabel": "{title} 可见方式",
"admin.settings.paymentVisibleMethods.methodHint": "控制前台结算页是否展示该方式,以及展示时使用的来源键。",
"admin.settings.paymentVisibleMethods.sourceLabel": "支付来源",
"admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。",
"admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。",
"admin.settings.payment.configGuide": "查看支付配置说明",
"admin.settings.payment.findProvider": "查看支持的支付方式",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
"admin.settings.openaiExperimentalScheduler.lowRatePriorityTitle": "低倍率优先",
"admin.settings.openaiExperimentalScheduler.lowRatePriorityDescription": "开启后优先选择计费倍率较低的账号;倍率相同时,再比较账号优先级和当前负载等。启用实验调度策略后,此开关不生效。",
"admin.settings.openaiExperimentalScheduler.oauthRateTitle": "OAuth 调度参考倍率",
"admin.settings.openaiExperimentalScheduler.oauthRatePriorityDescription": "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。",
"admin.settings.openaiExperimentalScheduler.oauthRateWeightedDescription": "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。",
"admin.settings.openaiExperimentalScheduler.stickyWeightedTitle": "粘性加权",
"admin.settings.openaiExperimentalScheduler.stickyWeightedDescription": "开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。",
"admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle": "订阅优先",
"admin.settings.openaiExperimentalScheduler.subscriptionPriorityDescription": "开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。",
"admin.settings.openaiExperimentalScheduler.weightsTitle": "调度权值覆盖",
"admin.settings.openaiExperimentalScheduler.weightsDescription": "留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。",
"admin.settings.openaiExperimentalScheduler.defaultPlaceholder": "配置/默认:{value}",
"admin.settings.openaiExperimentalScheduler.topKLabel": "TopK",
"admin.settings.openaiExperimentalScheduler.priorityWeight": "优先级",
"admin.settings.openaiExperimentalScheduler.loadWeight": "负载",
"admin.settings.openaiExperimentalScheduler.queueWeight": "排队",
"admin.settings.openaiExperimentalScheduler.errorRateWeight": "错误率",
"admin.settings.openaiExperimentalScheduler.ttftWeight": "首包延迟",
"admin.settings.openaiExperimentalScheduler.resetWeight": "重置窗口",
"admin.settings.openaiExperimentalScheduler.quotaHeadroomWeight": "额度余量",
"admin.settings.openaiExperimentalScheduler.upstreamCostWeight": "计费倍率",
"admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性",
"admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性",
"admin.settings.upstreamBillingProbe.title": "上游倍率自动探测",
"admin.settings.upstreamBillingProbe.description": "定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。",
"admin.settings.upstreamBillingProbe.enabled": "启用全局自动探测",
"admin.settings.upstreamBillingProbe.enabledHint": "开启后,仅对账号自身已启用自动检测的账号执行定时探测。",
"admin.settings.upstreamBillingProbe.intervalMinutes": "探测周期(分钟)",
"admin.settings.upstreamBillingProbe.intervalHint": "范围 5–1440 分钟。",
"admin.settings.upstreamBillingProbe.saved": "上游倍率自动探测设置已保存",
"admin.settings.upstreamBillingProbe.saveFailed": "保存上游倍率自动探测设置失败",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
"admin.settings.platformQuota.platform": "平台",
"admin.settings.platformQuota.daily": "日限额 (USD)",
"admin.settings.platformQuota.weekly": "周限额 (USD)",
"admin.settings.platformQuota.monthly": "月限额 (USD, 30天滚动)",
"admin.settings.platformQuota.placeholder": "不限",
"admin.settings.defaults.defaultPlatformQuotas": "默认平台限额(注册时分配)",
"admin.settings.defaults.defaultPlatformQuotasHint": "新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。",
"admin.settings.defaults.platformQuotaNotice": "月限额为 30 天滚动窗口,非自然月",
"admin.settings.authSourceDefaults.platformQuotasOverride": "平台限额覆盖",
"admin.settings.authSourceDefaults.platformQuotasOverrideHint": "留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。",
};
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string>) =>
(translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`),
locale: localeRef,
}),
};
});
const AppLayoutStub = { template: "<div><slot /></div>" };
const ToggleStub = defineComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
inheritAttrs: false,
setup(props, { attrs, emit }) {
return () =>
h("input", {
...attrs,
class: "toggle-stub",
type: "checkbox",
checked: props.modelValue,
onChange: (event: Event) => {
emit("update:modelValue", (event.target as HTMLInputElement).checked);
},
});
},
});
const SelectStub = defineComponent({
props: {
modelValue: {
type: [String, Number, Boolean, null],
default: "",
},
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: "",
},
},
emits: ["update:modelValue", "change"],
setup(props, { emit }) {
const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
emit("update:modelValue", target.value);
const option =
(props.options as Array<Record<string, unknown>>).find(
(item) => String(item.value ?? "") === target.value,
) ?? null;
emit("change", target.value, option);
};
return () =>
h(
"select",
{
class: "select-stub",
value: props.modelValue ?? "",
"data-placeholder": props.placeholder,
onChange,
},
(props.options as Array<Record<string, unknown>>).map((option) =>
h(
"option",
{
key: `${String(option.value ?? "")}:${String(option.label ?? "")}`,
value: option.value as string,
},
String(option.label ?? ""),
),
),
);
},
});
const ImageUploadStub = defineComponent({
props: {
modelValue: {
type: String,
default: "",
},
uploadLabel: {
type: String,
default: "",
},
removeLabel: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "",
},
},
setup(props) {
return () =>
h("div", {
class: "image-upload-stub",
"data-model-value": props.modelValue,
"data-upload-label": props.uploadLabel,
"data-remove-label": props.removeLabel,
"data-placeholder": props.placeholder,
});
},
});
const baseSettingsResponse = {
registration_enabled: true,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
default_balance: 0,
default_concurrency: 1,
default_subscriptions: [],
site_name: "Sub2API",
site_logo: "",
site_subtitle: "",
api_base_url: "",
contact_info: "",
doc_url: "",
home_content: "",
hide_ccs_import_button: false,
table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
backend_mode_enabled: false,
custom_menu_items: [],
custom_endpoints: [],
frontend_url: "",
smtp_host: "",
smtp_port: 587,
smtp_username: "",
smtp_password_configured: false,
smtp_from_email: "",
smtp_from_name: "",
smtp_use_tls: true,
turnstile_enabled: false,
turnstile_site_key: "",
turnstile_secret_key_configured: false,
api_key_acl_trust_forwarded_ip: true,
forwarded_client_ip_headers: [],
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: "",
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: "",
wechat_connect_enabled: true,
wechat_connect_app_id: "wx-app-id-123",
wechat_connect_app_secret_configured: true,
wechat_connect_open_enabled: false,
wechat_connect_mp_enabled: true,
wechat_connect_mode: "mp",
wechat_connect_scopes: "",
wechat_connect_redirect_url:
"https://admin.example.com/api/v1/auth/oauth/wechat/callback",
wechat_connect_frontend_redirect_url: "/auth/wechat/callback",
oidc_connect_enabled: false,
oidc_connect_provider_name: "OIDC",
oidc_connect_client_id: "",
oidc_connect_client_secret_configured: false,
oidc_connect_issuer_url: "",
oidc_connect_discovery_url: "",
oidc_connect_authorize_url: "",
oidc_connect_token_url: "",
oidc_connect_userinfo_url: "",
oidc_connect_jwks_url: "",
oidc_connect_scopes: "openid email profile",
oidc_connect_redirect_url: "",
oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
oidc_connect_token_auth_method: "client_secret_post",
oidc_connect_use_pkce: true,
oidc_connect_validate_id_token: true,
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false,
oidc_connect_userinfo_email_path: "",
oidc_connect_userinfo_id_path: "",
oidc_connect_userinfo_username_path: "",
enable_model_fallback: false,
fallback_model_anthropic: "",
fallback_model_openai: "",
fallback_model_gemini: "",
fallback_model_antigravity: "",
enable_identity_patch: false,
identity_patch_prompt: "",
ops_monitoring_enabled: false,
ops_realtime_monitoring_enabled: false,
ops_query_mode_default: "auto",
ops_metrics_interval_seconds: 60,
min_claude_code_version: "",
max_claude_code_version: "",
allow_ungrouped_key_scheduling: false,
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
enable_cch_signing: false,
enable_claude_oauth_system_prompt_injection: true,
claude_oauth_system_prompt: "",
claude_oauth_system_prompt_blocks: "",
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
payment_enabled: true,
payment_min_amount: 1,
payment_max_amount: 10000,
payment_daily_limit: 50000,
payment_order_timeout_minutes: 30,
payment_max_pending_orders: 3,
payment_enabled_types: [],
payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1,
payment_subscription_usd_to_cny_rate: 0,
payment_recharge_fee_rate: 0,
payment_load_balance_strategy: "round-robin",
payment_product_name_prefix: "",
payment_product_name_suffix: "",
payment_help_image_url: "",
payment_help_text: "",
payment_cancel_rate_limit_enabled: false,
payment_cancel_rate_limit_max: 10,
payment_cancel_rate_limit_window: 1,
payment_cancel_rate_limit_unit: "day",
payment_cancel_rate_limit_window_mode: "rolling",
payment_visible_method_alipay_source: "alipay_direct",
payment_visible_method_wxpay_source: "invalid-source",
payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true,
openai_low_upstream_rate_priority_enabled: false,
openai_oauth_scheduling_rate_multiplier: 1,
openai_advanced_scheduler_enabled: false,
openai_advanced_scheduler_sticky_weighted_enabled: false,
openai_advanced_scheduler_subscription_priority_enabled: false,
openai_advanced_scheduler_lb_top_k: "",
openai_advanced_scheduler_weight_priority: "",
openai_advanced_scheduler_weight_load: "",
openai_advanced_scheduler_weight_queue: "",
openai_advanced_scheduler_weight_error_rate: "",
openai_advanced_scheduler_weight_ttft: "",
openai_advanced_scheduler_weight_reset: "",
openai_advanced_scheduler_weight_quota_headroom: "",
openai_advanced_scheduler_weight_upstream_cost: "",
openai_advanced_scheduler_weight_previous_response: "",
openai_advanced_scheduler_weight_session_sticky: "",
openai_advanced_scheduler_effective_lb_top_k: "7",
openai_advanced_scheduler_effective_weight_priority: "1",
openai_advanced_scheduler_effective_weight_load: "1",
openai_advanced_scheduler_effective_weight_queue: "0.7",
openai_advanced_scheduler_effective_weight_error_rate: "0.8",
openai_advanced_scheduler_effective_weight_ttft: "0.5",
openai_advanced_scheduler_effective_weight_reset: "0",
openai_advanced_scheduler_effective_weight_quota_headroom: "0",
openai_advanced_scheduler_effective_weight_upstream_cost: "0",
openai_advanced_scheduler_effective_weight_previous_response: "5",
openai_advanced_scheduler_effective_weight_session_sticky: "3",
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: "",
subscription_expiry_notify_enabled: true,
account_quota_notify_enabled: false,
account_quota_notify_emails: [],
// 平台限额嵌套字段(新后端契约)
default_platform_quotas: {
anthropic: { daily: null, weekly: null, monthly: null },
openai: { daily: null, weekly: 12.5, monthly: null },
gemini: { daily: null, weekly: null, monthly: 200 },
antigravity: { daily: null, weekly: null, monthly: null },
},
};
function mountView() {
return mount(SettingsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
Select: SelectStub,
Toggle: ToggleStub,
Icon: true,
ConfirmDialog: true,
PaymentProviderList: true,
PaymentProviderDialog: true,
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
});
}
async function openPaymentTab(wrapper: ReturnType<typeof mountView>) {
const paymentTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.payment"));
expect(paymentTabButton).toBeDefined();
await paymentTabButton?.trigger("click");
await flushPromises();
}
async function openSecurityTab(wrapper: ReturnType<typeof mountView>) {
const securityTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.security"));
expect(securityTabButton).toBeDefined();
await securityTabButton?.trigger("click");
await flushPromises();
}
async function openGatewayTab(wrapper: ReturnType<typeof mountView>) {
const gatewayTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.gateway"));
expect(gatewayTabButton).toBeDefined();
await gatewayTabButton?.trigger("click");
await flushPromises();
}
async function openUsersTab(wrapper: ReturnType<typeof mountView>) {
const usersTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.users"));
expect(usersTabButton).toBeDefined();
await usersTabButton?.trigger("click");
await flushPromises();
}
describe("admin SettingsView payment visible method controls", () => {
beforeEach(() => {
getSettings.mockReset();
updateSettings.mockReset();
getWebSearchEmulationConfig.mockReset();
updateWebSearchEmulationConfig.mockReset();
getAdminApiKey.mockReset();
getOverloadCooldownSettings.mockReset();
getRateLimit429CooldownSettings.mockReset();
updateRateLimit429CooldownSettings.mockReset();
getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset();
getUpstreamBillingProbeSettings.mockReset();
updateUpstreamBillingProbeSettings.mockReset();
getOllamaCloudUsageSettings.mockReset();
updateOllamaCloudUsageSettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
updateProvider.mockReset();
createProvider.mockReset();
deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();
showSuccess.mockReset();
localeRef.value = "zh-CN";
getSettings.mockResolvedValue({ ...baseSettingsResponse });
updateSettings.mockImplementation(async (payload) => ({
...baseSettingsResponse,
...payload,
}));
getWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
});
updateWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
});
getAdminApiKey.mockResolvedValue({
exists: false,
masked_key: "",
});
getOverloadCooldownSettings.mockResolvedValue({
enabled: true,
cooldown_minutes: 10,
});
getRateLimit429CooldownSettings.mockResolvedValue({
enabled: true,
cooldown_seconds: 5,
});
updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload);
getStreamTimeoutSettings.mockResolvedValue({
enabled: true,
action: "temp_unsched",
temp_unsched_minutes: 5,
threshold_count: 3,
threshold_window_minutes: 10,
});
getRectifierSettings.mockResolvedValue({
enabled: true,
thinking_signature_enabled: true,
thinking_budget_enabled: true,
apikey_signature_enabled: false,
apikey_signature_patterns: [],
});
getBetaPolicySettings.mockResolvedValue({
rules: [],
});
getUpstreamBillingProbeSettings.mockResolvedValue({
enabled: true,
interval_minutes: 30,
});
updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload);
getOllamaCloudUsageSettings.mockResolvedValue({
enabled: false,
interval_minutes: 60,
debounce_minutes: 1,
});
updateOllamaCloudUsageSettings.mockImplementation(async (payload) => payload);
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({
items: [],
});
getProviders.mockResolvedValue({
data: [],
});
fetchPublicSettings.mockResolvedValue(undefined);
adminSettingsFetch.mockResolvedValue(undefined);
});
it("renders panel rate limit card and saves settings", async () => {
getPanelRateLimitSettings.mockClear();
updatePanelRateLimitSettings.mockClear();
getPanelRateLimitSettings.mockResolvedValue({
enabled: true,
user_rpm: 240,
heavy_rpm: 60,
exempt_admin: true,
public_ip_rpm: 300,
});
updatePanelRateLimitSettings.mockImplementation(async (payload) => payload);
const wrapper = mountView();
await flushPromises();
expect(getPanelRateLimitSettings).toHaveBeenCalled();
expect(wrapper.text()).toContain("admin.settings.panelRateLimit.title");
expect(wrapper.text()).toContain("admin.settings.panelRateLimit.proxySafeNote");
const userRpmInput = wrapper.find('[data-testid="panel-rate-limit-user-rpm"]');
expect(userRpmInput.exists()).toBe(true);
await userRpmInput.setValue("120");
const saveButton = wrapper.find('[data-testid="panel-rate-limit-save"]');
expect(saveButton.exists()).toBe(true);
await saveButton.trigger("click");
await flushPromises();
expect(updatePanelRateLimitSettings).toHaveBeenCalledWith({
enabled: true,
user_rpm: 120,
heavy_rpm: 60,
exempt_admin: true,
public_ip_rpm: 300,
});
expect(showSuccess).toHaveBeenCalled();
});
it("does not render legacy visible payment method controls", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
expect(wrapper.text()).not.toContain("可见方式");
expect(wrapper.text()).not.toContain("支付来源");
});
it("loads, edits, validates, and saves forwarded client-IP headers", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
api_key_acl_trust_forwarded_ip: false,
forwarded_client_ip_headers: ["cf-connecting-ip", "X-Real-IP"],
});
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
const card = wrapper
.findAll(".card")
.find((node) => node.text().includes("admin.settings.apiKeyAcl.title"));
expect(card).toBeDefined();
const toggle = card!.get('input[type="checkbox"]');
expect((toggle.element as HTMLInputElement).checked).toBe(false);
expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false);
await toggle.setValue(true);
expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(2);
expect(card!.text()).toContain("Cf-Connecting-Ip");
expect(card!.text()).toContain("X-Real-Ip");
showError.mockClear();
const input = card!.get('[data-testid="forwarded-client-ip-headers-input"]');
await input.setValue("x-client-ip");
await input.trigger("keydown", { key: "Enter" });
await input.setValue("X-CLIENT-IP");
await input.trigger("keydown", { key: "Enter" });
await input.setValue("invalid header");
await input.trigger("keydown", { key: "Enter" });
expect(showError).toHaveBeenCalledTimes(1);
expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(3);
const realIpTag = card!
.findAll('[data-testid="forwarded-client-ip-header-tag"]')
.find((tag) => tag.text().includes("X-Real-Ip"));
expect(realIpTag).toBeDefined();
await realIpTag!.get("button").trigger("click");
expect(card!.text()).not.toContain("X-Real-Ip");
await toggle.setValue(false);
expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false);
await toggle.setValue(true);
expect(card!.text()).toContain("X-Client-Ip");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
api_key_acl_trust_forwarded_ip: true,
forwarded_client_ip_headers: ["Cf-Connecting-Ip", "X-Client-Ip"],
}),
);
});
it("links payment guidance to README sections instead of removed payment docs", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const paymentLinks = wrapper
.findAll("a")
.filter((node) =>
["查看支付配置说明", "查看支持的支付方式"].includes(node.text()),
);
expect(paymentLinks).toHaveLength(2);
expect(paymentLinks[0]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md",
);
expect(paymentLinks[1]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式",
);
for (const link of paymentLinks) {
expect(link.attributes("href")).toContain("docs/PAYMENT");
}
});
it("does not submit legacy visible payment method settings", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
const payload = updateSettings.mock.calls[0]?.[0];
expect(payload).not.toHaveProperty("payment_visible_method_alipay_source");
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_source");
expect(payload).not.toHaveProperty("payment_visible_method_alipay_enabled");
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled");
});
it("submits the admin recharge affiliate rebate setting", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
affiliate_enabled: true,
affiliate_admin_recharge_enabled: true,
});
const wrapper = mountView();
await flushPromises();
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
affiliate_admin_recharge_enabled: true,
}),
);
});
it("submits Anthropic cache TTL injection gateway setting", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
enable_anthropic_cache_ttl_1h_injection: true,
});
const wrapper = mountView();
await flushPromises();
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
enable_anthropic_cache_ttl_1h_injection: true,
}),
);
});
it("submits message cache_control rewrite gateway setting", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
rewrite_message_cache_control: true,
});
const wrapper = mountView();
await flushPromises();
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
rewrite_message_cache_control: true,
}),
);
});
it("submits Claude OAuth system prompt injection gateway settings", async () => {
const blocks = `[{"type":"text","text":"custom block","cache_control":true}]`;
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
enable_claude_oauth_system_prompt_injection: false,
claude_oauth_system_prompt_blocks: blocks,
});
const wrapper = mountView();
await flushPromises();
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
enable_claude_oauth_system_prompt_injection: false,
}),
);
const payload = updateSettings.mock.calls[0][0] as {
claude_oauth_system_prompt_blocks: string;
};
expect(JSON.parse(payload.claude_oauth_system_prompt_blocks)).toEqual([
{
enabled: true,
type: "text",
text: "custom block",
cache_control: {
type: "ephemeral",
ttl: "5m",
},
},
]);
});
it("submits Antigravity user agent version gateway setting", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
antigravity_user_agent_version: "1.23.2",
});
const wrapper = mountView();
await flushPromises();
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
antigravity_user_agent_version: "1.23.2",
}),
);
});
it("updates provider enablement immediately and reloads providers", async () => {
const provider = {
id: 7,
provider_key: "alipay",
name: "Official Alipay",
config: {},
supported_types: ["alipay"],
enabled: false,
payment_mode: "",
refund_enabled: false,
allow_user_refund: false,
limits: "",
sort_order: 0,
};
getProviders.mockReset();
getProviders
.mockResolvedValueOnce({ data: [provider] })
.mockResolvedValueOnce({ data: [{ ...provider, enabled: true }] });
updateProvider.mockResolvedValue({ data: { ...provider, enabled: true } });
const PaymentProviderListStub = defineComponent({
emits: ["toggleField"],
setup(_, { emit }) {
return () =>
h(
"button",
{
class: "provider-toggle-stub",
onClick: () => emit("toggleField", provider, "enabled"),
},
"toggle provider",
);
},
});
const wrapper = mount(SettingsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
Select: SelectStub,
Toggle: ToggleStub,
Icon: true,
ConfirmDialog: true,
PaymentProviderList: PaymentProviderListStub,
PaymentProviderDialog: true,
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
});
await flushPromises();
await openPaymentTab(wrapper);
await wrapper.get(".provider-toggle-stub").trigger("click");
await flushPromises();
expect(updateProvider).toHaveBeenCalledWith(7, { enabled: true });
expect(getProviders).toHaveBeenCalledTimes(2);
});
it("renders advanced scheduler copy as local experimental gateway policy", async () => {
const wrapper = mountView();
await flushPromises();
expect(wrapper.text()).toContain("OpenAI 实验调度策略");
expect(wrapper.text()).toContain(
"默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑",
);
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
});
it("loads and saves upstream billing probe settings from the gateway tab", async () => {
getUpstreamBillingProbeSettings.mockResolvedValueOnce({
enabled: false,
interval_minutes: 45,
});
const wrapper = mountView();
await flushPromises();
await openGatewayTab(wrapper);
const card = wrapper.get('[data-testid="upstream-billing-probe-settings"]');
expect(card.isVisible()).toBe(true);
expect(card.text()).toContain("上游倍率自动探测");
expect(
(card.get('[data-testid="upstream-billing-probe-enabled"]').element as HTMLInputElement)
.checked,
).toBe(false);
expect(card.find('[data-testid="upstream-billing-probe-interval"]').exists()).toBe(false);
await card.get('[data-testid="upstream-billing-probe-enabled"]').setValue(true);
await card.get('[data-testid="upstream-billing-probe-interval"]').setValue(60);
await card.get('[data-testid="upstream-billing-probe-save"]').trigger("click");
await flushPromises();
expect(updateUpstreamBillingProbeSettings).toHaveBeenCalledWith({
enabled: true,
interval_minutes: 60,
});
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
});
it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => {
const wrapper = mountView();
await flushPromises();
await openGatewayTab(wrapper);
const card = wrapper.get('[data-testid="ollama-cloud-usage-global-settings"]');
expect(card.isVisible()).toBe(true);
expect(
(card.get('[data-testid="ollama-cloud-usage-global-enabled"]').element as HTMLInputElement)
.checked,
).toBe(false);
expect(card.find('[data-testid="ollama-cloud-usage-global-interval"]').exists()).toBe(false);
await card.get('[data-testid="ollama-cloud-usage-global-enabled"]').setValue(true);
await card.get('[data-testid="ollama-cloud-usage-global-debounce"]').setValue(3);
await card.get('[data-testid="ollama-cloud-usage-global-interval"]').setValue(90);
await card.get('[data-testid="ollama-cloud-usage-global-save"]').trigger("click");
await flushPromises();
expect(updateOllamaCloudUsageSettings).toHaveBeenCalledWith({
enabled: true,
interval_minutes: 90,
debounce_minutes: 3,
});
});
it("places and explains rate controls for both scheduling modes", async () => {
const wrapper = mountView();
await flushPromises();
expect(
wrapper.find('[data-testid="openai-oauth-scheduling-rate-multiplier"]').exists(),
).toBe(false);
const lowRateToggle = wrapper.get('[data-testid="openai-low-rate-priority-toggle"]');
await lowRateToggle.setValue(true);
const priorityModeText = wrapper.text();
expect(priorityModeText).toContain(
"同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。",
);
expect(priorityModeText.indexOf("低倍率优先")).toBeLessThan(
priorityModeText.indexOf("OAuth 调度参考倍率"),
);
expect(priorityModeText.indexOf("OAuth 调度参考倍率")).toBeLessThan(
priorityModeText.indexOf("OpenAI 实验调度策略"),
);
const oauthRateInput = wrapper.get(
'[data-testid="openai-oauth-scheduling-rate-multiplier"]',
);
await oauthRateInput.setValue("0.05");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
openai_low_upstream_rate_priority_enabled: true,
openai_oauth_scheduling_rate_multiplier: 0.05,
}),
);
await wrapper
.get('[data-testid="openai-advanced-scheduler-toggle"]')
.setValue(true);
expect(
wrapper.find('[data-testid="openai-low-rate-priority-toggle"]').exists(),
).toBe(false);
expect(
wrapper.find('[data-testid="openai-oauth-scheduling-rate-multiplier"]').exists(),
).toBe(true);
const weightedModeText = wrapper.text();
expect(weightedModeText).toContain(
"同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。",
);
expect(weightedModeText).not.toContain(
"OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。",
);
expect(weightedModeText.indexOf("订阅优先")).toBeLessThan(
weightedModeText.indexOf("OAuth 调度参考倍率"),
);
expect(weightedModeText.indexOf("OAuth 调度参考倍率")).toBeLessThan(
weightedModeText.indexOf("调度权值覆盖"),
);
expect(weightedModeText).toContain("计费倍率");
});
it("passes translated upload and remove labels to the payment help image uploader", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const imageUploads = wrapper.findAll(".image-upload-stub");
expect(imageUploads.length).toBeGreaterThan(0);
const paymentHelpImageUpload = imageUploads.find(
(node) => node.attributes("data-placeholder") === "admin.settings.payment.helpImagePlaceholder",
);
expect(paymentHelpImageUpload).toBeDefined();
expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片");
expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除");
});
it("normalizes null supported_types from API so provider card stays visible", async () => {
// Backend returns null for supported_types when the list is empty
// (Go nil slice → JSON null). Without normalization, ProviderCard's
// isSelected() throws TypeError on null.includes(), causing the card
// to vanish from the list.
const providerWithNullTypes = {
id: 42,
provider_key: "easypay",
name: "EasyPay",
config: {},
supported_types: null as unknown as string[],
enabled: true,
payment_mode: "",
refund_enabled: false,
allow_user_refund: false,
limits: "",
sort_order: 0,
};
getProviders.mockReset();
getProviders.mockResolvedValue({ data: [providerWithNullTypes] });
let receivedProviders: Array<Record<string, unknown>> = [];
const PaymentProviderListCapture = defineComponent({
props: {
providers: {
type: Array,
default: () => [],
},
},
setup(props) {
receivedProviders = props.providers as Array<Record<string, unknown>>;
return () => h("div", { class: "provider-list-capture" });
},
});
const wrapper = mount(SettingsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
Select: SelectStub,
Toggle: ToggleStub,
Icon: true,
ConfirmDialog: true,
PaymentProviderList: PaymentProviderListCapture,
PaymentProviderDialog: true,
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
});
await flushPromises();
await openPaymentTab(wrapper);
// The provider should still be in the list
expect(receivedProviders.length).toBe(1);
// supported_types should be normalized to an empty array, not null
expect(Array.isArray(receivedProviders[0].supported_types)).toBe(true);
expect(receivedProviders[0].supported_types).toEqual([]);
});
});
describe("admin SettingsView wechat connect controls", () => {
beforeEach(() => {
getSettings.mockReset();
updateSettings.mockReset();
getWebSearchEmulationConfig.mockReset();
updateWebSearchEmulationConfig.mockReset();
getAdminApiKey.mockReset();
getOverloadCooldownSettings.mockReset();
getRateLimit429CooldownSettings.mockReset();
updateRateLimit429CooldownSettings.mockReset();
getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
updateProvider.mockReset();
createProvider.mockReset();
deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();
showSuccess.mockReset();
getSettings.mockResolvedValue({
...baseSettingsResponse,
payment_visible_method_wxpay_source: "official_wxpay",
});
updateSettings.mockImplementation(async (payload) => ({
...baseSettingsResponse,
payment_visible_method_wxpay_source: "official_wxpay",
...payload,
}));
getWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
});
updateWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
});
getAdminApiKey.mockResolvedValue({
exists: false,
masked_key: "",
});
getOverloadCooldownSettings.mockResolvedValue({
enabled: true,
cooldown_minutes: 10,
});
getRateLimit429CooldownSettings.mockResolvedValue({
enabled: true,
cooldown_seconds: 5,
});
updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload);
getStreamTimeoutSettings.mockResolvedValue({
enabled: true,
action: "temp_unsched",
temp_unsched_minutes: 5,
threshold_count: 3,
threshold_window_minutes: 10,
});
getRectifierSettings.mockResolvedValue({
enabled: true,
thinking_signature_enabled: true,
thinking_budget_enabled: true,
apikey_signature_enabled: false,
apikey_signature_patterns: [],
});
getBetaPolicySettings.mockResolvedValue({
rules: [],
});
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({
items: [],
});
getProviders.mockResolvedValue({
data: [],
});
fetchPublicSettings.mockResolvedValue(undefined);
adminSettingsFetch.mockResolvedValue(undefined);
});
it("loads and echoes WeChat Connect fields from the backend payload", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
expect(
(
wrapper.get('[data-testid="wechat-connect-mp-app-id"]')
.element as HTMLInputElement
).value,
).toBe("wx-app-id-123");
expect(
(
wrapper.get('[data-testid="wechat-connect-open-enabled"]')
.element as HTMLInputElement
).checked,
).toBe(false);
expect(
(
wrapper.get('[data-testid="wechat-connect-mp-enabled"]')
.element as HTMLInputElement
).checked,
).toBe(true);
expect(wrapper.find('[data-testid="wechat-connect-scopes"]').exists()).toBe(
false,
);
expect(
wrapper
.get('[data-testid="wechat-connect-mp-app-secret"]')
.attributes("placeholder"),
).toContain("密钥已配置");
expect(
(
wrapper.get('[data-testid="wechat-connect-frontend-redirect-url"]')
.element as HTMLInputElement
).value,
).toBe("/auth/wechat/callback");
});
it("links GitHub OAuth Apps guide to GitHub developer settings", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
github_oauth_enabled: true,
});
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]');
expect(link.text()).toContain("OAuth Apps");
expect(link.attributes("href")).toBe("https://github.com/settings/developers");
expect(link.attributes("target")).toBe("_blank");
expect(link.attributes("rel")).toContain("noopener");
});
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
await wrapper
.get('[data-testid="wechat-connect-mp-app-id"]')
.setValue("wx-app-id-updated");
await wrapper
.get('[data-testid="wechat-connect-mp-app-secret"]')
.setValue("new-secret");
await wrapper
.get('[data-testid="wechat-connect-open-enabled"]')
.setValue(true);
await wrapper
.get('[data-testid="wechat-connect-mp-enabled"]')
.setValue(true);
await wrapper
.get('[data-testid="wechat-connect-redirect-url"]')
.setValue("https://admin.example.com/api/v1/auth/oauth/wechat/callback");
await wrapper
.get('[data-testid="wechat-connect-frontend-redirect-url"]')
.setValue("/auth/wechat/callback");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
wechat_connect_enabled: true,
wechat_connect_app_id: "wx-app-id-updated",
wechat_connect_open_enabled: true,
wechat_connect_mp_enabled: true,
wechat_connect_mp_app_id: "wx-app-id-updated",
wechat_connect_mp_app_secret: "new-secret",
wechat_connect_redirect_url:
"https://admin.example.com/api/v1/auth/oauth/wechat/callback",
wechat_connect_frontend_redirect_url: "/auth/wechat/callback",
}),
);
expect(
(
wrapper.get('[data-testid="wechat-connect-mp-app-secret"]')
.element as HTMLInputElement
).value,
).toBe("");
expect(
wrapper
.get('[data-testid="wechat-connect-mp-app-secret"]')
.attributes("placeholder"),
).toContain("密钥已配置");
});
it("collapses auth source defaults until the source is enabled", async () => {
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
expect(
(
wrapper.get('[data-testid="auth-source-email-enabled"]')
.element as HTMLInputElement
).checked,
).toBe(false);
expect(
wrapper.find('[data-testid="auth-source-email-panel"]').exists(),
).toBe(false);
expect(wrapper.text()).not.toContain("注册即授权");
await wrapper
.get('[data-testid="auth-source-email-enabled"]')
.setValue(true);
expect(
wrapper.find('[data-testid="auth-source-email-panel"]').exists(),
).toBe(true);
expect(wrapper.text()).toContain("首次绑定时授权");
});
it("preserves optional OIDC compatibility flags instead of forcing them on save", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
oidc_connect_enabled: true,
oidc_connect_use_pkce: false,
oidc_connect_validate_id_token: false,
});
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
oidc_connect_use_pkce: false,
oidc_connect_validate_id_token: false,
}),
);
});
});
describe("admin SettingsView platform quota matrix", () => {
beforeEach(() => {
getSettings.mockReset();
updateSettings.mockReset();
getWebSearchEmulationConfig.mockReset();
updateWebSearchEmulationConfig.mockReset();
getAdminApiKey.mockReset();
getOverloadCooldownSettings.mockReset();
getRateLimit429CooldownSettings.mockReset();
updateRateLimit429CooldownSettings.mockReset();
getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
updateProvider.mockReset();
createProvider.mockReset();
deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();
showSuccess.mockReset();
localeRef.value = "zh-CN";
getSettings.mockResolvedValue({ ...baseSettingsResponse });
updateSettings.mockImplementation(async (payload) => ({
...baseSettingsResponse,
...payload,
}));
getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "" });
getOverloadCooldownSettings.mockResolvedValue({});
getRateLimit429CooldownSettings.mockResolvedValue({});
updateRateLimit429CooldownSettings.mockResolvedValue({});
getStreamTimeoutSettings.mockResolvedValue({});
getRectifierSettings.mockResolvedValue({});
getBetaPolicySettings.mockResolvedValue({});
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({ items: [] });
getProviders.mockResolvedValue({ data: [] });
});
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 5 平台行", async () => {
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
expect(getSettings).toHaveBeenCalled();
const html = wrapper.html();
// 表格行的平台字段:font-mono 渲染纯英文 platform key
expect(html).toContain("anthropic");
expect(html).toContain("openai");
expect(html).toContain("gemini");
expect(html).toContain("antigravity");
});
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 5 平台)", async () => {
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalled();
const lastCallArgs = updateSettings.mock.calls.at(-1);
expect(lastCallArgs).toBeDefined();
const payload = lastCallArgs![0] as Record<string, unknown>;
// 应携带嵌套对象,而非扁平字段
expect(payload).toHaveProperty("default_platform_quotas");
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
const platforms = ["anthropic", "openai", "gemini", "antigravity", "grok"];
for (const p of platforms) {
expect(quotas).toHaveProperty(p);
const pq = quotas[p] as Record<string, unknown>;
expect(pq).toHaveProperty("daily");
expect(pq).toHaveProperty("weekly");
expect(pq).toHaveProperty("monthly");
}
// 不应存在旧扁平字段
expect(payload).not.toHaveProperty("default_platform_quota_anthropic_daily");
expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly");
});
it("加载后 form.default_platform_quotas 含全 5 平台,从嵌套 JSON 正确读取数值", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
default_platform_quotas: {
anthropic: { daily: 5, weekly: null, monthly: null },
openai: { daily: null, weekly: 12.5, monthly: null },
// gemini / antigravity 缺失 → 应被归一化为全 null
},
});
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
expect(quotas["anthropic"]?.["daily"]).toBe(5);
expect(quotas["openai"]?.["weekly"]).toBe(12.5);
// 缺失平台应补全为 null
expect(quotas["gemini"]).toEqual({ daily: null, weekly: null, monthly: null });
expect(quotas["antigravity"]).toEqual({ daily: null, weekly: null, monthly: null });
});
it("空输入(v-model.number 产出 \"\")在提交时清洗为 null 而非空字符串", async () => {
// 模拟后端返回带有 anthropic daily 值的配额
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
default_platform_quotas: {
anthropic: { daily: 10, weekly: null, monthly: null },
openai: { daily: null, weekly: null, monthly: null },
gemini: { daily: null, weekly: null, monthly: null },
antigravity: { daily: null, weekly: null, monthly: null },
},
});
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
// 找到 anthropic daily 输入框并清空(模拟用户删除值)
const inputs = wrapper.findAll('input[type="number"]');
const anthropicDailyInput = inputs.find((i) => {
const parent = i.element.closest("tr");
return parent?.textContent?.includes("anthropic");
});
if (anthropicDailyInput) {
// 设置为空字符串,模拟 v-model.number 在清空时产出 ""
await anthropicDailyInput.setValue("");
}
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
// 不管输入是什么,提交值应为 null(而非 "" 或 NaN)
expect(quotas["anthropic"]?.["daily"]).toBe(null);
});
});