mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
上一提交只修了自己新加的两处弹窗,全站其余 7 处是同一缺陷:所有对话框
共用 id="modal-wrap"(CSS `#modal-wrap:not(:empty){display:flex}`)且可以叠
加(seed-key 提示就盖在密钥页上),而
`const w = $("#modal-wrap"); w.remove()` 移除的是**文档里第一个**,不是用户
刚提交的那一个。
逐处改为两种安全写法:
- 能拿到按钮的(saveSource / saveTemplate / sortScopeSave / scopeSave /
keyQuotaSave / createKey / downloadStatsCsv / downloadKeysCsv /
scrAddFromForm):`btn.closest("#modal-wrap")`
- 拿不到按钮的:新增 `closeTopModal()` 取**最后一个**(用户看到的那个),
并作为所有 `if (w) w.remove()` 之后的兜底
- 顺带把 sortScopeSave / downloadStatsCsv / downloadKeysCsv / scrAddFromForm
的签名补上 btn / this 参数 —— 否则 .closest 恒为 null,表单永远不关
同时修一个相邻的既有 bug:`openTemplateModal` 的 `.map((t) => ...)` 用 t 做
循环变量,模板字面量里又调 t("srcEdit"),t 被遮蔽成对象 ⇒ 打开模板管理器
直接抛 `t is not a function`,整个弹窗渲染失败(main 上就有,git show 确认)。
参数改名 tpl。修后模板管理器完整渲染(浏览器实测:DeepSeek / 智谱 / Kimi /
SiliconFlow 各行 + Edit/Delete 按钮文案全部正常,零异常)。
判据从 2 处扩到全量(internal/gateway/ui_quota_contract_test.go,+3 例):
- 全文档扫描:任何 `.remove()` 配裸 `$("#modal-wrap")` 即失败
- 9 个关闭对话框的处理器必须走 .closest 或 closeTopModal
- closeTopModal 必须取 all.length - 1(首尾颠倒就是原 bug)
- 用 .closest 的处理器,其签名必须真的有 btn 参数 —— 否则查找恒为 null,
表单永远不关
5/5 变异全被抓:sortScopeSave 去 btn 参数、closeTopModal 取第一个、scopeSave
退回裸选择器、saveSource 退回裸选择器、downloadKeysCsv 删兜底。
浏览器实测(共享 Chromium CDP,真实进程,每处都插入一个「decoy」弹窗
占据文档首位,复现原 bug 的触发条件):
- scopeSave / keyQuotaSave / scrAddFromForm / saveSource / saveTemplate
五个处理器:自己的表单关、decoy 保留 ✓
- 零 JS 异常
★ 测试自身踩了两个坑,都不是代码问题:① `document.querySelector(sel) && .click()`
在 CDP 里求值为 undefined,改成箭头函数;② 编辑模板时没填名字就点保存,
saveTemplate 因 `if (!nm)` 早退、fetch 零调用 —— 一开始我把这个误读成
「修复失效」,加 fetch 拦截 + 读 #s-name 的值才定位到是测试数据缺失。
⇒ 「点按钮没反应」要先分清是「事件没触发」「请求失败」还是「早退」。
(cherry picked from commit b5c3fea0bb)
271 lines
9.6 KiB
Go
271 lines
9.6 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIDialogClosuresGoThroughSafePaths pins the rule across the whole
|
|
// document by data flow rather than by pattern: every handler that closes a
|
|
// dialog must do it one of the two safe ways. A handler could contain a
|
|
// correct .closest() and still close the wrong dialog on another path.
|
|
func TestUIDialogClosuresGoThroughSafePaths(t *testing.T) {
|
|
src := stripJSComments(uiSource(t))
|
|
for _, fn := range []string{
|
|
"downloadStatsCsv", "downloadKeysCsv", "saveSource", "saveTemplate",
|
|
"scrAddFromForm", "sortScopeSave", "scopeSave", "keyQuotaSave", "createKey",
|
|
} {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
t.Errorf("%s not found", fn)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, `closest("#modal-wrap")`) && !strings.Contains(body, "closeTopModal()") {
|
|
t.Errorf("%s closes a dialog with neither .closest nor closeTopModal", fn)
|
|
}
|
|
}
|
|
if !strings.Contains(src, "function closeTopModal(") {
|
|
t.Error("closeTopModal helper is missing")
|
|
}
|
|
}
|
|
|
|
// The helper must pick the LAST dialog (the topmost one the user sees), not the
|
|
// first — that inversion is the whole bug.
|
|
func TestUICloseTopModalTakesTheLast(t *testing.T) {
|
|
body, ok := jsFunctionBody(uiSource(t), "closeTopModal")
|
|
if !ok {
|
|
t.Fatal("closeTopModal not found")
|
|
}
|
|
if !strings.Contains(body, "all.length - 1") {
|
|
t.Errorf("closeTopModal does not take the last dialog:\n\t%s", oneLine(body))
|
|
}
|
|
}
|
|
|
|
// Handlers that resolve their dialog from a button must actually receive one:
|
|
// a signature without the parameter means the .closest() silently yields null
|
|
// and the save leaves its form stranded on screen.
|
|
func TestUIDialogHandlersReceiveTheirButton(t *testing.T) {
|
|
src := stripJSComments(uiSource(t))
|
|
for _, fn := range []string{
|
|
"downloadStatsCsv", "saveSource", "scrAddFromForm", "sortScopeSave",
|
|
"scopeSave", "keyQuotaSave", "createKey",
|
|
} {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
t.Errorf("%s not found", fn)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, "closest(\"#modal-wrap\")") {
|
|
continue // uses closeTopModal only
|
|
}
|
|
sig := body[:strings.Index(body, ")")+1]
|
|
if !strings.Contains(sig, "btn") {
|
|
t.Errorf("%s uses .closest(\"#modal-wrap\") but its signature %s has no button parameter —\n"+
|
|
"the lookup would always be null and the form would never close", fn, oneLine(sig))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|