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 the same class of bug the api() contract test pins: reviewing inline // JS by eye does not catch it, and the symptom ("the dialog did not close") // points away from the cause. // 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) { // 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(uiSource(t)) 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: 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 { 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() } // Every handler that closes a dialog must do it one of the two safe ways. func TestUIDialogClosuresGoThroughSafePaths(t *testing.T) { src := stripJSComments(uiSource(t)) for _, fn := range []string{ "downloadStatsCsv", "downloadKeysCsv", "saveSource", "saveTemplate", "scrAddFromForm", "sortScopeSave", "scopeSave", "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. func TestUIDialogHandlersReceiveTheirButton(t *testing.T) { src := stripJSComments(uiSource(t)) for _, fn := range []string{ "downloadStatsCsv", "saveSource", "scrAddFromForm", "sortScopeSave", "scopeSave", "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)) } } } // Quotas belong to the scope entries, so putScope only has to ship the scope // list — the caps travel inside it. What must NOT come back is a key-wide // total: it would be a second budget able to drift out of sync with the models // it is supposed to cover. func TestUIPutScopeShipsOnlyScopeQuotas(t *testing.T) { body, ok := jsFunctionBody(uiSource(t), "putScope") if !ok { t.Fatal("putScope not found") } code := stripJSComments(body) if !strings.Contains(code, "models:") || !strings.Contains(code, "scopes") { t.Error("putScope must ship the scope list the caps live in") } for _, gone := range []string{"kquota", "kreqquota", "kperiod", "khours"} { if strings.Contains(code, gone) { t.Errorf("putScope still references the removed key-wide quota field %q", gone) } } } // A model brick carries its own budgets, and the scope editor reads and writes // both of them: dropping req_quota on the round trip would silently lift a // request cap every time someone edited a token cap. func TestUIScopeEditorRoundTripsBothQuotas(t *testing.T) { src := stripJSComments(uiSource(t)) for _, fn := range []string{"scopeHtml", "readScopes", "scopeEdit", "scopeSave", "scopeQuotaBadge"} { if _, ok := jsFunctionBody(src, fn); !ok { t.Errorf("%s not found in the UI source", fn) } } for _, fn := range []string{"scopeHtml", "readScopes", "scopeSave", "scopeQuotaBadge"} { body, ok := jsFunctionBody(src, fn) if !ok { continue } if !strings.Contains(body, "req_quota") && !strings.Contains(body, "reqquota") { t.Errorf("%s does not carry req_quota — a request cap would be lost on edit", fn) } } if form, ok := jsFunctionBody(src, "scopeEdit"); ok && !strings.Contains(form, "sc-reqs") { t.Error("the scope editor has no request-quota input") } } // The whole key-wide quota surface must be gone from the UI: a badge or a // button reading a field the server no longer has would render "undefined" or // silently do nothing. func TestUIHasNoKeyWideQuotaSurface(t *testing.T) { src := uiSource(t) for _, gone := range []string{ "keyCapBadges", "keyQuotaEdit", "keyQuotaSave", "kq-tokens", "kq-reqs", "kq-period", "kq-hours", "kc-tokens", "kc-reqs", "kc-period", "kc-hours", "data-kquota", "data-kreqquota", "data-kperiod", "data-khours", } { if strings.Contains(src, gone) { t.Errorf("UI still references the removed key-wide quota surface %q", gone) } } } // Existence of the strings is not enough: the badge has to READ the scope's // fields, and the editor has to read back what the brick 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 data attribute the writer never sets. func TestUIKeyQuotaDataflowIsLive(t *testing.T) { src := stripJSComments(uiSource(t)) badge, ok := jsFunctionBody(src, "scopeQuotaBadge") if !ok { t.Fatal("scopeQuotaBadge not found") } for _, field := range []string{"m.token_quota", "m.req_quota", "m.period"} { if !strings.Contains(badge, field) { t.Errorf("scopeQuotaBadge does not read %q — the cap would never show on the model brick", field) } } brick, ok := jsFunctionBody(src, "scopeHtml") if !ok { t.Fatal("scopeHtml not found") } edit, ok := jsFunctionBody(src, "scopeEdit") if !ok { t.Fatal("scopeEdit not found") } brickCode, editCode := stripJSComments(brick), stripJSComments(edit) reader := stripJSComments(mustBody(t, src, "readScopes")) for _, ds := range []string{"quota", "reqquota", "period", "hours"} { if !strings.Contains(brickCode, "data-"+ds+"=") { t.Errorf("scopeHtml does not write data-%s, so the editor has nothing to prefill", ds) } // the editor pre-fills through readScopes(), which is what walks the // bricks' data attributes — check the reader, not the form if !strings.Contains(reader, "dataset."+ds) { t.Errorf("readScopes does not read dataset.%s — editing a brick would save zeros over it", ds) } } // and the form must actually consume what readScopes produced for _, field := range []string{"sc.token_quota", "sc.req_quota", "sc.period", "sc.hours"} { if !strings.Contains(editCode, field) { t.Errorf("scopeEdit does not prefill from %q", field) } } } func mustBody(t *testing.T, src, fn string) string { t.Helper() b, ok := jsFunctionBody(src, fn) if !ok { t.Fatalf("%s not found", fn) } return b } // 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) 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) } } 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) } } }