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- 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. 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 }