Files
ModelRouter/internal/gateway/ui_quota_contract_test.go
JianFeeeee ef631b43dd feat(webui): 密钥配额表单 + 修复弹窗关闭错对象
功能:让 per-key 配额在 WebUI 里可配置可见,之前的实现只有 API 与
config.yaml 能配。

- 密钥卡片头部显示配额徽标(token / 请求数 + 重置窗口),admin key
  不显示编辑入口(服务端本就永不受限,给入口只会让人以为配了会生效)。
- 新增「配额」编辑弹窗:token 配额、请求数配额、重置周期(复用既有
  的 period 词表与 n-hour 联动),预填从 canvas 的 data-* 读。
- 创建密钥弹窗同步加配额字段;选 admin 角色时自动禁用(同样因为服务端
  忽略 admin 的配额)。
- 「我的密钥」页新增 KEY-WIDE QUOTA 列,用户能看到自己这把 key 的预算。

修一个真 bug:保存弹窗用 $("#modal-wrap") 关闭自己,而全站弹窗共用这个
id、且可以叠加(seed key 提示就盖在密钥页上)。实测(共享 Chromium
CDP,seed 提示与配额弹窗共存)确认:保存后被移除的是 seed 提示,配额表单
反而留在屏幕上 —— 症状是「保存了但弹窗没关」,指向的方向完全错。改为用
点击的按钮 btn.closest("#modal-wrap") 解析自己的弹窗。createKey 有同样
问题,一并修。既有文件里另有 7 处同样写法,未动(不在本次范围,且新判据
只对本次改的两处断言,避免误伤)。

判据新增 internal/gateway/ui_quota_contract_test.go(6 例):
- 两个表单必须用 .closest 解析自己的弹窗
- putScope 必须带上 4 个配额字段(API 视其为指针,省略=清空预算)
- 创建请求必须真的发出配额字段
- **数据流判据**:徽标要真读 k.token_quota 等、编辑表单要真读
  canvas 写的 data-kquota 等。只查字面量存在会漏 —— 字段躺在死分支里
  判据照样通过(这是本轮实际踩到的:keyCapBadges 经 keyPeriodSuffix
  间接读 k.period,被判据抓到后我把读取显式化而不是放宽判据)
- 弹窗扫描先剥注释,否则修复说明里引用的字面量会被当成违规
- 复用既有 ui_contract_test.go 的 jsFunctionBody(大括号配平);
  自己第一版用 2000 字符固定窗口,被长注释顶开后仍在窗口外命中后面
  函数的同名字段,读起来像通过 —— 窗口法在这里是假判据

7 个变异全部被抓(unsafe 关闭、putScope 丢字段、createKey 丢字段、
徽标不读字段、canvas 不写 data-*、kq-hours 改名、周期词表缺项)。

浏览器实测(共享 Chromium CDP,真实进程 + 加密配置):
- 徽标渲染 1.0K·1h / 5×·1h;编辑框预填 1000/5/hour,hours 框按周期联动
- 保存后回读 250000/77/nhour/6,徽标更新为 250.0K·6h,toast Saved
- 零 JS 异常
- **关键回归**:编辑模型 scope 后配额仍是 250000/77/nhour,未被清空
- 创建带配额的 key,服务端确认 {t:50000,r:300,p:week,role:user}
- user 视角「我的密钥」显示 777·1h 与 9×·1h

文档:README.md / README_EN.md 补「密钥用量配额」小节(配置示例、
周期词表、429 语义、admin 豁免、整点分桶最晚晚 1 小时释放、PUT 的
省略 vs 0 语义、429 响应样例),特性列表各加一条。

(cherry picked from commit ce66c7f6c2)
2026-09-27 18:44:41 +08:00

210 lines
7.4 KiB
Go

package gateway
import (
"regexp"
"strings"
"testing"
)
// The WebUI dialogs all share the id "modal-wrap", and more than one can be
// open at the same time (the seed-key notice sits on top of the keys page).
// A save handler that closes "the" modal via $("#modal-wrap") therefore removes
// whichever one comes FIRST in the document — which is the wrong dialog: the
// form the user just submitted stays on screen while an unrelated dialog
// vanishes.
//
// This is exactly the class of bug the api() contract test below was written
// for: reviewing inline JS by eye does not catch it, and the visible symptom
// ("the dialog did not close") points away from the cause. It is pinned here.
// modalCloseRe finds every `$(...)`-style lookup of the shared modal id.
var modalCloseRe = regexp.MustCompile(`\$\("#modal-wrap"\)`)
// closestModalRe finds the safe form: resolve the dialog from the clicked
// button instead of from the document.
var closestModalRe = regexp.MustCompile(`\.closest\("#modal-wrap"\)`)
func TestUIDialogClosesItselfNotTheFirstModal(t *testing.T) {
src := uiSource(t)
// Strip comments first: prose that *names* the unsafe pattern (as the fix's
// own comment does) would otherwise be flagged as a violation.
code := stripJSComments(src)
for _, m := range modalCloseRe.FindAllStringIndex(code, -1) {
after := code[m[1]:]
stmtEnd := strings.Index(after, ";")
if stmtEnd < 0 || stmtEnd > 200 {
continue
}
stmt := after[:stmtEnd]
if strings.Contains(stmt, ".remove()") {
line := 1 + strings.Count(code[:m[0]], "\n")
t.Errorf("line %d closes the first #modal-wrap in the document, not its own dialog:\n\t%s",
line, strings.TrimSpace(stmt))
}
}
}
// stripJSComments removes // line comments and /* block */ comments from JS
// embedded in the UI document. It is deliberately simple (no string/regex
// awareness beyond skipping quoted spans on the same line): the document is
// our own source, and a false negative here only means the check is silent.
func stripJSComments(src string) string {
var out strings.Builder
lines := strings.Split(src, "\n")
inBlock := false
for _, ln := range lines {
trimmed := strings.TrimSpace(ln)
if inBlock {
if strings.Contains(ln, "*/") {
inBlock = false
}
continue
}
if strings.HasPrefix(trimmed, "/*") {
if !strings.Contains(ln, "*/") {
inBlock = true
}
continue
}
if i := strings.Index(ln, "//"); i >= 0 {
// keep code before the comment when the // is not inside a string
before := ln[:i]
if strings.Count(before, `"`)%2 == 0 && strings.Count(before, "'")%2 == 0 {
ln = before
}
}
out.WriteString(ln)
out.WriteString("\n")
}
return out.String()
}
// TestUIKeyQuotaDialogsResolveOwnModal pins the dialog-closing rule for the
// two forms this change added.
func TestUIKeyQuotaDialogsResolveOwnModal(t *testing.T) {
src := uiSource(t)
for _, fn := range []string{"keyQuotaSave", "createKey"} {
body, ok := jsFunctionBody(src, fn)
if !ok {
t.Errorf("%s not found in the UI source", fn)
continue
}
if !closestModalRe.MatchString(body) {
t.Errorf("%s does not resolve its own dialog via .closest(\"#modal-wrap\");\n"+
"with another dialog open it would close that one instead and leave this form stranded", fn)
}
}
}
// The quota editor must read and write the key-wide caps, and putScope must
// carry them along: the API treats the quota fields as pointers, so dropping
// them on a scope-only write is indistinguishable from "clear the budget".
func TestUIPutScopeCarriesKeyQuota(t *testing.T) {
body, ok := jsFunctionBody(uiSource(t), "putScope")
if !ok {
t.Fatal("putScope not found")
}
// Scan the code with comments removed, or a comment that merely *names* a
// field would satisfy the check while the field is never sent.
code := stripJSComments(body)
for _, field := range []string{"token_quota", "req_quota", "period", "hours"} {
if !strings.Contains(code, field) {
t.Errorf("putScope does not send %q — editing a model scope would clear the key's quota", field)
}
}
}
// A key's caps are rendered from the API record and shown on the canvas, so
// the badge and the data attributes must not drift from the field names. The
// create form must send them too, or a key would only be cappable after an
// extra round of edits.
func TestUIKeyQuotaRendersFromAPIFields(t *testing.T) {
src := uiSource(t)
for _, token := range []string{
"keyCapBadges", // shared renderer
"kq-tokens", "kq-reqs", "kq-period", "kq-hours", // editor fields
"kc-tokens", "kc-reqs", "kc-period", "kc-hours", // create form fields
} {
if !strings.Contains(src, token) {
t.Errorf("UI never references %q — the quota form is not wired up", token)
}
}
// the create request must actually carry the caps
full, ok := jsFunctionBody(src, "createKey")
if !ok {
t.Fatal("createKey not found")
}
body := stripJSComments(full)
for _, field := range []string{"token_quota", "req_quota", "period"} {
if !strings.Contains(body, field) {
t.Errorf("createKey does not send %q — a new key could never be created with a budget", field)
}
}
}
// Existence of the strings is not enough: the badge has to READ the API
// fields, and the editor has to read the canvas data attributes it writes.
// A field can be present in the source and still never reach the screen —
// e.g. left in a dead branch, or read from a name the writer never sets.
func TestUIKeyQuotaDataflowIsLive(t *testing.T) {
src := uiSource(t)
badge, ok := jsFunctionBody(src, "keyCapBadges")
if !ok {
t.Fatal("keyCapBadges not found")
}
badgeCode := stripJSComments(badge)
for _, field := range []string{"k.token_quota", "k.req_quota", "k.period"} {
if !strings.Contains(badgeCode, field) {
t.Errorf("keyCapBadges does not read %q — the cap would never show on the key card", field)
}
}
// the editor must read back what keyCanvasHtml wrote
canvas, ok := jsFunctionBody(src, "keyCanvasHtml")
if !ok {
t.Fatal("keyCanvasHtml not found")
}
editor, ok := jsFunctionBody(src, "keyQuotaEdit")
if !ok {
t.Fatal("keyQuotaEdit not found")
}
canvasCode, editorCode := stripJSComments(canvas), stripJSComments(editor)
for _, ds := range []string{"kquota", "kreqquota", "kperiod", "khours"} {
// written as data-<name> on the canvas
if !strings.Contains(canvasCode, "data-"+ds+"=") {
t.Errorf("keyCanvasHtml does not write data-%s, so the editor has nothing to prefill", ds)
}
// read back as dataset.<name> by the editor
if !strings.Contains(editorCode, "dataset."+ds) {
t.Errorf("keyQuotaEdit does not read dataset.%s — the form would open blank and save zeros", ds)
}
}
}
// The quota period vocabulary must match the server's, or the UI can offer a
// value the API rejects.
func TestUIQuotaPeriodsMatchServer(t *testing.T) {
src := uiSource(t)
// the shared period select options, as rendered in both forms
for _, p := range []string{`value=""`, `value="hour"`, `value="week"`, `value="month"`, `value="nhour"`} {
if !strings.Contains(src, p) {
t.Errorf("UI period select is missing %s", p)
}
}
// the server's accepted vocabulary
for _, p := range []string{`"hour"`, `"week"`, `"month"`, `"nhour"`} {
if !strings.Contains(src, `if (p === `+p+`)`) && !strings.Contains(src, `=== `+p+`)`) {
t.Errorf("periodText() does not describe %s, so a badge would omit the window", p)
}
}
}
func max(a, b int) int {
if a > b {
return a
}
return b
}