mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
bf0657b 修好了 api_key,但 upsert 仍会重写整个源,于是请求无法表达的字段
一律被重置为零值。这四个字段的后果都不是"少个配置项":
- api_key_env 丢失 ⇒ 盘上无明文密钥的源变成无凭据源,写操作返回 200,
下一次调用上游才 401。而 README 恰恰把这个特性当作卖点在宣传。
- proxy_url 丢失 ⇒ 一个走代理的上游变成直连(或反之),且完全无声。
- timeout / queue_timeout 丢失 ⇒ 退回默认 120s / 60s。
触发路径不是只有脚本:WebUI 的 saveSource() 发的 payload 只含表单上的
11 个字段,而 editSource() 表单里根本没有这 4 项 ⇒ 运维在界面上改个并发数
就会静默清掉它们。
修法用「存在性」语义而不是「空即继承」:
- 不传 → 保留已存值(部分更新的客户端要的就是这个)
- 传了 → 覆盖,包括传空串表示清空
api_key 刻意保留它原有的「空即继承」规则,不跟着改成指针:该规则已随
v1.7.6 发布,脚本依赖它;而凭据丢失比代理丢失严重得多。两个字段的失败
模式相反,所以规则相反——这一条写进了两处注释。
另一处是差一点的:config.Source 把 Timeout/QueueTimeout 标成 json:"-",
所以 reveal 接口的结构体序列化**根本不返回它们**。表单读不到 → 输入框
恒空 → 而输入框每次都回传 → 每存一次就把 timeout 清零。等于把刚修好的
丢字段换个方向又造了一个。因此 reveal 分支现在显式返回 duration 字符串。
判据:
- TestWebUIEditPayloadPreservesRoutingFields 用的是 WebUI 真实 payload
的逐字节副本,并同时断言"确实改动的字段生效",否则"什么都不写"也能过
- TestSourceEditPreservesAPIKeyEnv 单独盯 api_key_env(唯一造成凭据丢失的)
- CanBeSet / CanBeCleared 分别盯两个方向:只有"空即继承"的实现过不了
CanBeCleared(清空代理框会永远保留旧代理)
- 持久化判据**重新加载 config.yaml 并按语义比对**:300s 会被重新序列化成
5m0s,按字符串匹配是假红(我自己先踩了一次)
- TestSourcePayloadCoversEveryEditableField 用反射卡住"这一类":新增
Source 字段而没接到 API 上时立刻变红。反射查结构体而非 marshal 结果,
因为指针 + omitempty 会合法地从序列化输出里消失,那正是"未提及"信号
- 三个 UI 契约判据把 JS 侧也钉住(表单必须回传、必须从 reveal 读)
变异验证(每次都先确认 build 通过,再数红格):
1. 去掉覆盖逻辑 → 7 个判据红
2. 改成"空即继承" → CanBeCleared + ClearIsScoped 红
3. reveal 不返回 duration → TestSourceRevealExposesDurations 红
4. 表单不回传 api_key_env → TestUIEditFormRoundTrips... 红
顺带修正 /api/v1 索引:DELETE /api/keys 的路径段写的是 {name},实际是 key
本身;PUT /api/keys/{key} 实现了却没列。
全量 + vet + race 全绿;WebUI 内联脚本过 node --check。
483 lines
16 KiB
Go
483 lines
16 KiB
Go
package gateway
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The WebUI talks to this package through a tiny `api(path, options)` helper
|
|
// that wraps fetch(). fetch() defaults to GET when no method is given, so an
|
|
// options object without `method` silently turns a DELETE/PUT into a GET —
|
|
// the request hits the wrong branch of the handler and the user sees a
|
|
// confusing error like "use GET /api/keys" while nothing is deleted.
|
|
//
|
|
// This has now bitten twice: ab20f1b fixed delSource/delTemplate, and the very
|
|
// same pattern was still live in delKey (deleting a gateway key was impossible)
|
|
// and delAdapter. Reviewing 4000+ lines of inline JS by eye clearly does not
|
|
// catch it, so it is pinned here instead.
|
|
|
|
// apiCallRe finds the start of every api(...) call in the UI source.
|
|
var apiCallRe = regexp.MustCompile(`\bapi\(`)
|
|
|
|
// uiSource returns the embedded WebUI document.
|
|
func uiSource(t *testing.T) string {
|
|
t.Helper()
|
|
b, err := uiFS.ReadFile("ui/index.html")
|
|
if err != nil {
|
|
t.Fatalf("read embedded ui: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// extractCall returns the source of the balanced api(...) call starting at the
|
|
// opening parenthesis index, plus the line number it starts on.
|
|
func extractCall(src string, openIdx int) (string, bool) {
|
|
depth := 0
|
|
for i := openIdx; i < len(src); i++ {
|
|
switch src[i] {
|
|
case '(':
|
|
depth++
|
|
case ')':
|
|
depth--
|
|
if depth == 0 {
|
|
return src[openIdx : i+1], true
|
|
}
|
|
case '\n':
|
|
// A call spanning more than ~25 lines is not one of ours; bail out
|
|
// rather than scanning the rest of the document.
|
|
if i-openIdx > 4000 {
|
|
return "", false
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func lineOf(src string, idx int) int {
|
|
return strings.Count(src[:idx], "\n") + 1
|
|
}
|
|
|
|
// TestUIEditFormRoundTripsEveryOptionalSourceField pins the JS half of the
|
|
// source-edit contract.
|
|
//
|
|
// The server distinguishes "absent" from "explicitly empty" for proxy_url,
|
|
// api_key_env and timeout (they are pointers). That only helps if the form
|
|
// SENTS them — a form that omits them falls back to "inherit", so clearing the
|
|
// proxy box would silently keep the old proxy, which is the exact bug in the
|
|
// other direction.
|
|
//
|
|
// It must also send the api_key_env value it was given, since that is the one
|
|
// field whose loss is invisible until the next upstream call.
|
|
func TestUIEditFormRoundTripsEveryOptionalSourceField(t *testing.T) {
|
|
src := uiSource(t)
|
|
body, ok := jsFunctionBody(src, "saveSource")
|
|
if !ok {
|
|
t.Fatal("saveSource() not found in the WebUI")
|
|
}
|
|
for _, field := range []string{"proxy_url", "api_key_env", "timeout"} {
|
|
if !strings.Contains(body, field+":") {
|
|
t.Errorf("saveSource() does not send %q; the server treats an absent "+
|
|
"field as \"keep the stored value\", so the form could never clear it", field)
|
|
}
|
|
}
|
|
// The form's inputs must be filled from the values the server sends, or a
|
|
// save would post an empty box and clear a configured field.
|
|
for _, read := range []string{`$("#s-proxy")`, `$("#s-keyenv")`, `$("#s-timeout")`} {
|
|
if !strings.Contains(src, read+".value") {
|
|
t.Errorf("the source form never reads %s — it would post an empty value", read)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIEditFormReadsDurationsFromTheRevealView: config.Source tags
|
|
// Timeout/QueueTimeout `json:"-"`, so the durations only reach the form if the
|
|
// reveal endpoint adds them explicitly. The form MUST read them (see above),
|
|
// which makes this API response load-bearing rather than informational.
|
|
func TestUIEditFormReadsDurationsFromTheRevealView(t *testing.T) {
|
|
src := uiSource(t)
|
|
for _, id := range []string{"s-proxy", "s-keyenv", "s-timeout"} {
|
|
if !strings.Contains(src, `id="`+id+`"`) {
|
|
t.Errorf("source form input #%s is missing", id)
|
|
}
|
|
}
|
|
// The timeout box must render through the duration helper. Reading a raw
|
|
// time.Duration number would show nanoseconds in a box that expects "300s".
|
|
if !strings.Contains(src, "timeoutText(") {
|
|
t.Error("the timeout input does not go through timeoutText(); a raw " +
|
|
"time.Duration JSON number is nanoseconds and would be uneditable")
|
|
}
|
|
}
|
|
|
|
// TestUIEditFormSendsEverySourceField is the mirror of
|
|
// TestSourcePayloadCoversEveryEditableField on the JavaScript side: each field
|
|
// the server accepts must be submitted by the form, or the server's upsert has
|
|
// nothing to store for it.
|
|
func TestUIEditFormSendsEverySourceField(t *testing.T) {
|
|
src := uiSource(t)
|
|
body, ok := jsFunctionBody(src, "saveSource")
|
|
if !ok {
|
|
t.Fatal("saveSource() not found")
|
|
}
|
|
// Fields the edit dialog owns. proxy_url / api_key_env / timeout are
|
|
// covered by the round-trip test above; this one catches the rest.
|
|
// models and meta are local variables submitted by Go shorthand
|
|
// (payload = { models, meta }), so they are matched as bare identifiers.
|
|
for _, field := range []string{
|
|
"name:", "base_url:", "api_key:", "adapter:", "endpoint:",
|
|
"image_endpoint:", "max_concurrent:", "rpm:", "temperature:",
|
|
"models,", "meta,",
|
|
} {
|
|
if !strings.Contains(body, field) {
|
|
t.Errorf("saveSource() does not send %s", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
|
|
// object also declares an HTTP method (or is a GET that only passes an
|
|
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
|
|
// never reached.
|
|
func TestUIAPICallsDeclareMethod(t *testing.T) {
|
|
src := uiSource(t)
|
|
var offenders []string
|
|
for _, m := range apiCallRe.FindAllStringIndex(src, -1) {
|
|
open := m[1] - 1 // index of '('
|
|
call, ok := extractCall(src, open)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// No options object at all => a plain GET, which is fine.
|
|
if !strings.Contains(call, "{") {
|
|
continue
|
|
}
|
|
if strings.Contains(call, "method:") {
|
|
continue
|
|
}
|
|
// A read that only carries an AbortSignal is a deliberate GET.
|
|
if strings.Contains(call, "signal:") && !strings.Contains(call, "body:") {
|
|
continue
|
|
}
|
|
offenders = append(offenders, fmt.Sprintf("line %d: %s", lineOf(src, open), oneLine(call)))
|
|
}
|
|
if len(offenders) > 0 {
|
|
t.Fatalf("api() called with an options object but no method (fetch will send GET):\n %s",
|
|
strings.Join(offenders, "\n "))
|
|
}
|
|
}
|
|
|
|
// TestUIDeleteHelpersUseDelete pins the specific helpers that remove things.
|
|
// Each must issue a DELETE; a GET here is the exact bug users reported as
|
|
// "cannot delete key: use GET /api/keys".
|
|
func TestUIDeleteHelpersUseDelete(t *testing.T) {
|
|
src := uiSource(t)
|
|
for _, fn := range []string{"delKey", "delAdapter", "delSource", "delTemplate"} {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
t.Errorf("helper %s() not found in the WebUI", fn)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, "api(") {
|
|
t.Errorf("%s() does not call api()", fn)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, `method: "DELETE"`) {
|
|
t.Errorf("%s() must issue a DELETE, got:\n%s", fn, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIMutatingHelpersUseWriteMethods covers the write (non-delete) helpers:
|
|
// each must declare an explicit POST/PUT/PATCH.
|
|
func TestUIMutatingHelpersUseWriteMethods(t *testing.T) {
|
|
src := uiSource(t)
|
|
want := map[string][]string{
|
|
"createKey": {`method: "POST"`},
|
|
"putScope": {`method: "PUT"`},
|
|
"uploadAdapter": {`method: "POST"`},
|
|
"saveAuto": {`method: "PUT"`, `method: "POST"`},
|
|
}
|
|
for fn, methods := range want {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
continue // helper renamed or absent: the method test above still guards it
|
|
}
|
|
found := false
|
|
for _, m := range methods {
|
|
if strings.Contains(body, m) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s() must declare one of %v", fn, methods)
|
|
}
|
|
}
|
|
}
|
|
|
|
// jsFunctionBody returns the source of `function name(` / `async function name(`
|
|
// up to its closing brace, using brace balancing.
|
|
func jsFunctionBody(src, name string) (string, bool) {
|
|
for _, prefix := range []string{"async function " + name + "(", "function " + name + "("} {
|
|
idx := strings.Index(src, prefix)
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
open := strings.Index(src[idx:], "{")
|
|
if open < 0 {
|
|
continue
|
|
}
|
|
open += idx
|
|
depth := 0
|
|
for i := open; i < len(src); i++ {
|
|
switch src[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return src[idx : i+1], true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func oneLine(s string) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len(s) > 140 {
|
|
return s[:140] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// TestUIRecordsPagingWiring guards the record table's paging plumbing. Two
|
|
// separate bugs made "scroll to the bottom" silently stop loading:
|
|
//
|
|
// 1. paintRecords rebuilt the whole table on every 5s poll whenever the row
|
|
// count did not exceed the first screen, wiping loaded pages and resetting
|
|
// scroll position.
|
|
// 2. IntersectionObserver only fires on TRANSITIONS. With a short list — or a
|
|
// page whose rows all duplicated the first screen — the sentinel stayed
|
|
// visible and never fired again.
|
|
//
|
|
// The fixes are a build-once flag, a scroll-position fallback, an initial
|
|
// viewport fill, and chaining when a page yields no new rows. None of that is
|
|
// reachable from Go, so the wiring is asserted structurally.
|
|
func TestUIRecordsPagingWiring(t *testing.T) {
|
|
src := uiSource(t)
|
|
|
|
paint, ok := jsFunctionBody(src, "paintRecords")
|
|
if !ok {
|
|
t.Fatal("paintRecords() not found")
|
|
}
|
|
if !strings.Contains(paint, "recsState.built") {
|
|
t.Error("paintRecords must build the table once (recsState.built) instead of rebuilding on every poll")
|
|
}
|
|
if !strings.Contains(paint, "afterbegin") {
|
|
t.Error("paintRecords must PREPEND newer rows on later polls, not rebuild the table")
|
|
}
|
|
if !strings.Contains(paint, "fillRecordsViewport") {
|
|
t.Error("paintRecords must fill the viewport so the sentinel can transition")
|
|
}
|
|
|
|
attach, ok := jsFunctionBody(src, "attachRecsObserver")
|
|
if !ok {
|
|
t.Fatal("attachRecsObserver() not found")
|
|
}
|
|
if !strings.Contains(attach, "IntersectionObserver") {
|
|
t.Error("attachRecsObserver must still use IntersectionObserver")
|
|
}
|
|
if !strings.Contains(attach, `addEventListener("scroll"`) {
|
|
t.Error("attachRecsObserver needs a scroll fallback: an already-visible sentinel never fires again")
|
|
}
|
|
|
|
load, ok := jsFunctionBody(src, "loadMoreRecords")
|
|
if !ok {
|
|
t.Fatal("loadMoreRecords() not found")
|
|
}
|
|
if !strings.Contains(load, "maxChain") {
|
|
t.Error("loadMoreRecords must chain when a page adds no new rows (the first page overlaps the first screen)")
|
|
}
|
|
if !strings.Contains(load, "signal: ctrl.signal") {
|
|
t.Error("loadMoreRecords must remain abortable")
|
|
}
|
|
|
|
fill, ok := jsFunctionBody(src, "fillRecordsViewport")
|
|
if !ok {
|
|
t.Fatal("fillRecordsViewport() not found")
|
|
}
|
|
for _, want := range []string{"scrollHeight", "clientHeight"} {
|
|
if !strings.Contains(fill, want) {
|
|
t.Errorf("fillRecordsViewport must compare %s to decide whether the list overflows", want)
|
|
}
|
|
}
|
|
|
|
rel, ok := jsFunctionBody(src, "releaseRecords")
|
|
if !ok {
|
|
t.Fatal("releaseRecords() not found")
|
|
}
|
|
for _, want := range []string{"observer.disconnect()", "removeEventListener", "abort()", "recsState.built = false"} {
|
|
if !strings.Contains(rel, want) {
|
|
t.Errorf("releaseRecords must clean up %s", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIThemeMatrix pins the theme system: every accent must define both a light
|
|
// and a dark variable block, so switching light/dark can never leave a theme
|
|
// half-defined (which shows up as unreadable text rather than as an error).
|
|
func TestUIThemeMatrix(t *testing.T) {
|
|
src := uiSource(t)
|
|
|
|
accents := []string{"sakura", "ocean", "violet", "mono"}
|
|
for _, a := range accents {
|
|
light := fmt.Sprintf(`html[data-accent=%q]{`, a)
|
|
dark := fmt.Sprintf(`html[data-theme="dark"][data-accent=%q]{`, a)
|
|
if !strings.Contains(src, light) {
|
|
t.Errorf("accent %q has no light-mode variable block (%s)", a, light)
|
|
}
|
|
// sakura is the default palette: its light values live in :root, and the
|
|
// generic dark block covers it.
|
|
if a != "sakura" && !strings.Contains(src, dark) {
|
|
t.Errorf("accent %q has no dark-mode variable block (%s)", a, dark)
|
|
}
|
|
if !strings.Contains(src, fmt.Sprintf(`data-accent="%s" title=`, a)) {
|
|
t.Errorf("accent %q has no picker button", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIMonoThemeIsFlat asserts the mono theme really is pure white / pure black:
|
|
// the surfaces must be opaque and the glass/blob decoration must be switched off,
|
|
// otherwise "#ffffff" renders as a grey translucent card over a gradient mesh.
|
|
func TestUIMonoThemeIsFlat(t *testing.T) {
|
|
src := uiSource(t)
|
|
|
|
lightBlock, ok := cssBlock(src, `html[data-accent="mono"]{`)
|
|
if !ok {
|
|
t.Fatal("mono light block not found")
|
|
}
|
|
for _, want := range []string{"--bg-s1:#ffffff", "--card:#ffffff", "--glass:0px"} {
|
|
if !strings.Contains(lightBlock, want) {
|
|
t.Errorf("mono light theme must set %s, got:\n%s", want, lightBlock)
|
|
}
|
|
}
|
|
if !strings.Contains(lightBlock, "--blob1:transparent") {
|
|
t.Error("mono light theme must disable the gradient blobs")
|
|
}
|
|
|
|
darkBlock, ok := cssBlock(src, `html[data-theme="dark"][data-accent="mono"]{`)
|
|
if !ok {
|
|
t.Fatal("mono dark block not found")
|
|
}
|
|
for _, want := range []string{"--bg-s1:#000000", "--glass:0px"} {
|
|
if !strings.Contains(darkBlock, want) {
|
|
t.Errorf("mono dark theme must set %s, got:\n%s", want, darkBlock)
|
|
}
|
|
}
|
|
if !strings.Contains(darkBlock, "--blob1:transparent") {
|
|
t.Error("mono dark theme must disable the gradient blobs")
|
|
}
|
|
|
|
// the flat-surface overrides must exist, or backdrop-filter turns #fff grey
|
|
for _, want := range []string{
|
|
`html[data-accent="mono"] #bgfx .blob{display:none}`,
|
|
`backdrop-filter:none`,
|
|
} {
|
|
if !strings.Contains(src, want) {
|
|
t.Errorf("mono theme needs the flat-surface override %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUICanvasColorsResolveVars guards a class of silent theme bug: Canvas 2D
|
|
// does not understand CSS var(), so assigning "var(--x)" to strokeStyle is a
|
|
// no-op that leaves the previous colour (often black) in place — invisible on a
|
|
// dark background. Theme-driven canvas colours must go through cssVar().
|
|
func TestUICanvasColorsResolveVars(t *testing.T) {
|
|
src := uiSource(t)
|
|
if !strings.Contains(src, "function cssVar(") {
|
|
t.Fatal("cssVar() helper missing: canvas colours cannot resolve CSS variables without it")
|
|
}
|
|
for _, prop := range []string{"strokeStyle", "fillStyle"} {
|
|
bad := fmt.Sprintf(`ctx.%s = "var(--`, prop)
|
|
if strings.Contains(src, bad) {
|
|
t.Errorf("ctx.%s assigned a raw CSS var(): Canvas ignores it silently, use cssVar()", prop)
|
|
}
|
|
}
|
|
}
|
|
|
|
// cssBlock returns the text of the CSS rule starting at selector, up to its
|
|
// closing brace.
|
|
func cssBlock(src, selector string) (string, bool) {
|
|
i := strings.Index(src, selector)
|
|
if i < 0 {
|
|
return "", false
|
|
}
|
|
j := strings.Index(src[i:], "}")
|
|
if j < 0 {
|
|
return "", false
|
|
}
|
|
return src[i : i+j+1], true
|
|
}
|
|
|
|
// The WebUI ships two locale objects (`zh` and `en`) that every rendered string
|
|
// goes through. A key added to only one of them does not error: t() falls back
|
|
// to `t("key") || "literal"` in some call sites and to the bare key string in
|
|
// others, so the user sees either a hardcoded local string or a raw key name.
|
|
// Nothing in the Go test suite noticed — this was found by hand while adding
|
|
// thImgs. Pin the key sets so a one-sided edit fails here instead of shipping.
|
|
func TestUILocaleKeyParity(t *testing.T) {
|
|
src := uiSource(t)
|
|
iZh := strings.Index(src, "zh: {")
|
|
iEn := strings.Index(src, "en: {")
|
|
if iZh < 0 || iEn < 0 || iEn < iZh {
|
|
t.Fatalf("locale blocks not found (zh=%d en=%d)", iZh, iEn)
|
|
}
|
|
zh := src[iZh:iEn]
|
|
en := src[iEn:]
|
|
// End the EN block at its closing brace, so keys from later objects
|
|
// (config templates etc.) do not pollute the comparison.
|
|
if end := strings.Index(en, "\n },"); end > 0 {
|
|
en = en[:end]
|
|
}
|
|
|
|
keyRe := regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:`)
|
|
collect := func(seg string) map[string]bool {
|
|
m := map[string]bool{}
|
|
for _, g := range keyRe.FindAllStringSubmatch(seg, -1) {
|
|
m[g[1]] = true
|
|
}
|
|
return m
|
|
}
|
|
zk, ek := collect(zh), collect(en)
|
|
// The locale markers themselves are the two block headers.
|
|
delete(zk, "zh")
|
|
delete(ek, "en")
|
|
|
|
var missingEN, missingZH []string
|
|
for k := range zk {
|
|
if !ek[k] {
|
|
missingEN = append(missingEN, k)
|
|
}
|
|
}
|
|
for k := range ek {
|
|
if !zk[k] {
|
|
missingZH = append(missingZH, k)
|
|
}
|
|
}
|
|
if len(missingEN) > 0 {
|
|
t.Errorf("translation keys present in zh but missing in en: %v", missingEN)
|
|
}
|
|
if len(missingZH) > 0 {
|
|
t.Errorf("translation keys present in en but missing in zh: %v", missingZH)
|
|
}
|
|
if len(zk) < 100 {
|
|
t.Errorf("only %d zh keys parsed — the block boundary regexp has drifted "+
|
|
"and this test is no longer checking anything", len(zk))
|
|
}
|
|
}
|