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