From ef05fd5c25cc0da726552def5b393bcfc0fad63d Mon Sep 17 00:00:00 2001 From: remib Date: Wed, 2 Sep 2026 08:49:43 +0200 Subject: [PATCH 1/5] JGC-540 - Fail api docs commands on stub OpenAPI bundle Homebrew builds from source embed only the dev stub catalog. Refuse `jf api docs search` and `jf api docs describe` with a clear error pointing users to the official release instead of returning partial results. Co-authored-by: Cursor --- docs/api-spec/parser.go | 15 ++++ docs/api-spec/parser_full_test.go | 4 + docs/api-spec/parser_test.go | 7 ++ docs/general/apidocsdescribe/help.go | 4 +- docs/general/apidocssearch/help.go | 4 +- general/api/docs_bundle.go | 10 +++ general/api/docs_describe.go | 3 + general/api/docs_describe_test.go | 123 +++----------------------- general/api/docs_search.go | 3 + general/api/docs_search_test.go | 127 +++------------------------ 10 files changed, 70 insertions(+), 230 deletions(-) create mode 100644 general/api/docs_bundle.go diff --git a/docs/api-spec/parser.go b/docs/api-spec/parser.go index fcb2cea7c..114522fab 100644 --- a/docs/api-spec/parser.go +++ b/docs/api-spec/parser.go @@ -209,6 +209,21 @@ func Info() Metadata { } } +// RequireFullBundle returns an error when this binary was built without the full +// rdme-admin OpenAPI bundle (e.g. plain `go build` or Homebrew source builds). +// `jf api docs search` and `jf api docs describe` call this before serving +// from the embedded catalog. +func RequireFullBundle() error { + if Bundle == "full" { + return nil + } + return fmt.Errorf( + "this command requires the full OpenAPI spec bundle, but this binary embeds the %q bundle "+ + "(typical of source/Homebrew builds). Install the official release from "+ + "https://install-cli.jfrog.io for full `jf api docs` support", + Bundle) +} + // isSpecFile reports whether name is a top-level OpenAPI YAML file that should // be parsed. Excludes rdme-admin's per-endpoint _order.yaml nav files and any // dotfile (including this package's own full/.placeholder.yaml). diff --git a/docs/api-spec/parser_full_test.go b/docs/api-spec/parser_full_test.go index a4bfb5f6b..a7d19e513 100644 --- a/docs/api-spec/parser_full_test.go +++ b/docs/api-spec/parser_full_test.go @@ -40,3 +40,7 @@ func TestInfo_Full(t *testing.T) { assert.Equal(t, "full", info.SpecBundle) assert.NotEmpty(t, info.SpecVersion, "full builds should report the rdme-admin version they were fetched from") } + +func TestRequireFullBundle_Full(t *testing.T) { + require.NoError(t, RequireFullBundle()) +} diff --git a/docs/api-spec/parser_test.go b/docs/api-spec/parser_test.go index a20eed0ae..400581676 100644 --- a/docs/api-spec/parser_test.go +++ b/docs/api-spec/parser_test.go @@ -119,6 +119,13 @@ func TestInfo_Stub(t *testing.T) { assert.Empty(t, info.SpecVersion, "stub builds have no rdme-admin version") } +func TestRequireFullBundle_Stub(t *testing.T) { + err := RequireFullBundle() + require.Error(t, err) + assert.Contains(t, err.Error(), `"stub"`) + assert.Contains(t, err.Error(), "install-cli.jfrog.io") +} + func TestIsSpecFile(t *testing.T) { tests := []struct { name string diff --git a/docs/general/apidocsdescribe/help.go b/docs/general/apidocsdescribe/help.go index f887c8e28..2a2f8b34f 100644 --- a/docs/general/apidocsdescribe/help.go +++ b/docs/general/apidocsdescribe/help.go @@ -48,10 +48,10 @@ Common patterns: $ jf api docs describe DELETE /worker/api/v1/workers/{workerKey} Gotchas: -- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface — an unresolved lookup names spec_bundle so you know whether that's the likely cause. +- Requires a binary built with the full OpenAPI bundle (official releases from https://install-cli.jfrog.io). Source/Homebrew builds exit with an error instead of returning partial results. - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - path must match the catalog exactly, including any literal {param} placeholders (e.g. "{workerKey}", not a real key) — copy it verbatim from 'jf api docs search' results rather than guessing. -- Not found (wrong method, wrong path, or the stub bundle lacks the operation) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. +- Not found (wrong method or path) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. - request_body's "example" field is only present when the underlying spec declares one; its absence doesn't mean the operation has no valid payload — check "properties" either way. - A request body property that is itself a nested object is reported by its type name (e.g. "PermissionResource") or "object" rather than being recursively flattened — only top-level fields are listed. diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go index cf3c20580..a8f14794c 100644 --- a/docs/general/apidocssearch/help.go +++ b/docs/general/apidocssearch/help.go @@ -27,7 +27,7 @@ EXAMPLES $ jf api docs search user --format table OUTPUT - JSON by default (this command exists primarily for agent consumption); pass --format table for a human-readable table instead. Each match includes the operation's method, path, summary, tags, a relevance score, and a "jf_api" field with a ready-to-run 'jf api' invocation for that operation. When the operation takes path/query parameters, they're listed under "parameters" (required ones marked). When it takes a JSON request body, its top-level fields (name, type, required, description, default) are listed under "request_body", and "jf_api" already includes a minimal -d '{...}' skeleton covering just the required fields — fill in real values before running it. Table view shows this as compact PARAMS/BODY columns ("*" marks a required field). An empty result set still reports which spec bundle was searched (spec_bundle) — a "stub" bundle may simply be missing the operation. Exits 0 even when no matches are found. "total_matches" and "truncated" report the full match count and whether --limit cut it down; when truncated, a warning is also printed to stderr (not stdout, so it never corrupts the JSON body or table).` + JSON by default (this command exists primarily for agent consumption); pass --format table for a human-readable table instead. Each match includes the operation's method, path, summary, tags, a relevance score, and a "jf_api" field with a ready-to-run 'jf api' invocation for that operation. When the operation takes path/query parameters, they're listed under "parameters" (required ones marked). When it takes a JSON request body, its top-level fields (name, type, required, description, default) are listed under "request_body", and "jf_api" already includes a minimal -d '{...}' skeleton covering just the required fields — fill in real values before running it. Table view shows this as compact PARAMS/BODY columns ("*" marks a required field). An empty result set still reports which spec bundle was searched (spec_bundle). Exits 0 even when no matches are found. "total_matches" and "truncated" report the full match count and whether --limit cut it down; when truncated, a warning is also printed to stderr (not stdout, so it never corrupts the JSON body or table). Requires a binary built with the full OpenAPI bundle (official releases from https://install-cli.jfrog.io); source/Homebrew builds exit with an error instead.` } func GetAIDescription() string { @@ -46,7 +46,7 @@ Common patterns: $ jf api docs search repository --limit 3 --format json Gotchas: -- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface. An empty match list includes spec_bundle so you know whether that's the likely cause. +- Requires a binary built with the full OpenAPI bundle (official releases from https://install-cli.jfrog.io). Source/Homebrew builds exit with an error instead of returning partial results. - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - Filters (--tag, --method) are hard excludes, applied before ranking/scoring. - A query with no contains-match anywhere falls back to fuzzy (typo-tolerant) matching, gated by a similarity floor to avoid coincidental false positives (e.g. "evidence" vs "environments"). Advanced: override the floor (0-1, default 0.6) with $JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN. diff --git a/general/api/docs_bundle.go b/general/api/docs_bundle.go new file mode 100644 index 000000000..237946402 --- /dev/null +++ b/general/api/docs_bundle.go @@ -0,0 +1,10 @@ +package api + +import ( + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +func requireFullApiDocsBundle() error { + return errorutils.CheckError(apispec.RequireFullBundle()) +} diff --git a/general/api/docs_describe.go b/general/api/docs_describe.go index afa8a41d6..0883df679 100644 --- a/general/api/docs_describe.go +++ b/general/api/docs_describe.go @@ -50,6 +50,9 @@ func runDescribeCmd(c *cli.Context, stdOut io.Writer) error { } method := c.Args().Get(0) path := normalizeApiPath(c.Args().Get(1)) + if err := requireFullApiDocsBundle(); err != nil { + return err + } info := apispec.Info() op, ok := apispec.FindOperation(method, path) diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go index 2d77a2a54..f8e1e2594 100644 --- a/general/api/docs_describe_test.go +++ b/general/api/docs_describe_test.go @@ -9,11 +9,9 @@ package api import ( "bytes" - "encoding/json" "testing" apispec "github.com/jfrog/jfrog-cli/docs/api-spec" - clientlog "github.com/jfrog/jfrog-client-go/utils/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli" @@ -49,44 +47,16 @@ func newDescribeApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { return app } -func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { - result := runDescribeJSON(t, "GET", "/access/api/v2/users") - assert.Equal(t, "GET", result["method"]) - assert.Equal(t, "/access/api/v2/users", result["path"]) - assert.Equal(t, "stub", result["spec_bundle"]) - assert.NotEmpty(t, result["parameters"]) - assert.Nil(t, result["request_body"]) - assert.NotEmpty(t, result["responses"]) - assert.Equal(t, "jf api /access/api/v2/users", result["jf_api"]) -} - -func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { - result := runDescribeJSON(t, "POST", "/access/api/v2/users") - assert.Equal(t, "POST", result["method"]) - - requestBody, ok := result["request_body"].(map[string]any) - require.True(t, ok, "createUser should carry a request_body") - required, ok := requestBody["required"].(bool) - require.True(t, ok) - assert.True(t, required) - properties, ok := requestBody["properties"].([]any) - require.True(t, ok) - assert.NotEmpty(t, properties) - - jfApi, ok := result["jf_api"].(string) - require.True(t, ok) - assert.Contains(t, jfApi, "-X POST") - assert.Contains(t, jfApi, "-d '") -} - -func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { - result := runDescribeJSON(t, "get", "/access/api/v2/users") - assert.Equal(t, "GET", result["method"]) -} +func TestRunDescribeCmd_StubBundleFails(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) -func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { - result := runDescribeJSON(t, "GET", "access/api/v2/users") - assert.Equal(t, "/access/api/v2/users", result["path"]) + require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"})) + require.Error(t, runErr) + assert.Contains(t, runErr.Error(), `"stub"`) + assert.Contains(t, runErr.Error(), "install-cli.jfrog.io") + assert.Empty(t, stdOut.String()) } func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { @@ -96,8 +66,7 @@ func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { require.NoError(t, app.Run([]string{"cmd", "GET", "/not/a/real/path"})) require.Error(t, runErr) - assert.Contains(t, runErr.Error(), "spec_bundle") - assert.Contains(t, runErr.Error(), "docs search") + assert.Contains(t, runErr.Error(), `"stub"`) } func TestRunDescribeCmd_WrongNumberOfArguments(t *testing.T) { @@ -120,73 +89,7 @@ func TestRunDescribeCmd_TableOutput(t *testing.T) { app := newDescribeApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "--format", "table", "POST", "/access/api/v2/users"})) - require.NoError(t, runErr) - assert.Contains(t, stdOut.String(), "METHOD") - assert.Contains(t, stdOut.String(), "POST") - assert.Contains(t, stdOut.String(), "REQUEST BODY") - assert.Contains(t, stdOut.String(), "RESPONSES") - assert.Contains(t, stdOut.String(), "JF API") -} - -// TestRunDescribeCmd_DefaultsToJSON verifies JSON is the default output format -// when --format is omitted, matching docs search's unconditional-JSON-default -// convention (see TestRunSearchCmd_DefaultsToJSON). -func TestRunDescribeCmd_DefaultsToJSON(t *testing.T) { - var out bytes.Buffer - prevLogger := clientlog.GetLogger() - t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) - clientlog.SetLogger(clientlog.NewLoggerWithFlags(clientlog.INFO, &out, 0)) - - var stdOut bytes.Buffer - var runErr error - app := newDescribeApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"})) - require.NoError(t, runErr) - - var result map[string]any - require.NoError(t, json.Unmarshal(out.Bytes(), &result), "default output should be parseable JSON") - assert.Equal(t, "stub", result["spec_bundle"]) - assert.Empty(t, stdOut.String(), "JSON goes through the logger's Output channel, not the stdOut writer") -} - -// TestSearchThenDescribe_EndToEnd guards the intended agent flow: a search -// result's method+path must resolve cleanly through describe, and describe's -// jf_api one-liner must match search's one-liner for the same operation (both -// call the shared jfApiOneLiner helper). -func TestSearchThenDescribe_EndToEnd(t *testing.T) { - matches := filterAndScore(stubOps(t), "user", "", "") - require.NotEmpty(t, matches) - top := matches[0] - - result := runDescribeJSON(t, top.Method, top.Path) - assert.Equal(t, top.Method, result["method"]) - assert.Equal(t, top.Path, result["path"]) - assert.Equal(t, top.JfApi, result["jf_api"]) -} - -// runDescribeJSON runs the describe app with JSON output (the default) and -// returns the parsed result body -- same technique as runSearchJSON in -// docs_search_test.go. The logger's Info/Warn channel is routed to a separate -// buffer from its Output channel so stray log lines can't corrupt the JSON -// body being unmarshaled here. -func runDescribeJSON(t *testing.T, method, path string) map[string]any { - t.Helper() - var jsonOut, logOut bytes.Buffer - logger := clientlog.NewLoggerWithFlags(clientlog.INFO, &logOut, 0) - logger.SetOutputWriter(&jsonOut) - prevLogger := clientlog.GetLogger() - t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) - clientlog.SetLogger(logger) - - var stdOut bytes.Buffer - var runErr error - app := newDescribeApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", method, path})) - require.NoError(t, runErr) - - var result map[string]any - require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON") - return result + require.Error(t, runErr) + assert.Contains(t, runErr.Error(), `"stub"`) + assert.Empty(t, stdOut.String()) } diff --git a/general/api/docs_search.go b/general/api/docs_search.go index ba1a52403..1eaf5df2a 100644 --- a/general/api/docs_search.go +++ b/general/api/docs_search.go @@ -109,6 +109,9 @@ func runSearchCmd(c *cli.Context, stdOut io.Writer) error { if limit <= 0 { limit = defaultLimit } + if err := requireFullApiDocsBundle(); err != nil { + return err + } ops, err := apispec.Operations() if err != nil { diff --git a/general/api/docs_search_test.go b/general/api/docs_search_test.go index d058abe0f..7c086658b 100644 --- a/general/api/docs_search_test.go +++ b/general/api/docs_search_test.go @@ -9,12 +9,10 @@ package api import ( "bytes" - "encoding/json" "slices" "testing" apispec "github.com/jfrog/jfrog-cli/docs/api-spec" - clientlog "github.com/jfrog/jfrog-client-go/utils/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli" @@ -194,132 +192,29 @@ func newSearchApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { return app } -// TestRunSearchCmd_DefaultsToJSON verifies JSON is the default output format -// when --format is omitted entirely -- this command exists primarily for -// agent consumption, unlike most other jf commands whose JSON default is -// gated on --ai-help/$JFROG_CLI_AI_HELP. Swaps the shared client logger since -// the JSON path writes via its Output channel, same technique as -// TestApiJSONErrorMode_EmitsJSONOnStdout in cli_test.go. -func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { - var out bytes.Buffer - prevLogger := clientlog.GetLogger() - t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) - clientlog.SetLogger(clientlog.NewLoggerWithFlags(clientlog.INFO, &out, 0)) - +// TestRunSearchCmd_StubBundleFails verifies that source/stub builds refuse to +// serve api docs search rather than returning a misleading partial catalog. +func TestRunSearchCmd_StubBundleFails(t *testing.T) { var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "user"})) - require.NoError(t, runErr) - - var result map[string]any - require.NoError(t, json.Unmarshal(out.Bytes(), &result), "default output should be parseable JSON") - assert.Equal(t, "stub", result["spec_bundle"]) - assert.Empty(t, stdOut.String(), "JSON goes through the logger's Output channel, not the stdOut writer") + require.Error(t, runErr) + assert.Contains(t, runErr.Error(), `"stub"`) + assert.Contains(t, runErr.Error(), "install-cli.jfrog.io") + assert.Empty(t, stdOut.String()) } -func TestRunSearchCmd_TableOutput(t *testing.T) { +func TestRunSearchCmd_StubBundleFailsTableFormat(t *testing.T) { var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "--format", "table", "user"})) - require.NoError(t, runErr) - assert.Contains(t, stdOut.String(), "METHOD") - assert.Contains(t, stdOut.String(), "/access/api/v2/users") -} - -func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "zzzznotreal"})) - require.NoError(t, runErr, "empty results must not be treated as a command failure") - assert.Contains(t, stdOut.String(), "spec_bundle=") - assert.Contains(t, stdOut.String(), "stub") -} - -func TestRunSearchCmd_LimitTruncates(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) - require.NoError(t, runErr) - // header + exactly one data row - lineCount := 0 - for _, b := range stdOut.Bytes() { - if b == '\n' { - lineCount++ - } - } - assert.Equal(t, 2, lineCount, "expected a header row plus exactly one match row") -} - -// runSearchJSON runs the search app with JSON output (the default) and -// returns the parsed result body plus whatever landed on the logger's -// Warn/Info/Error channel -- kept on a *separate* buffer from the JSON body's -// Output channel (clientlog.NewLoggerWithFlags points both at the same -// writer by default, which would otherwise interleave a truncation warning -// into the JSON bytes and break json.Unmarshal). -func runSearchJSON(t *testing.T, args ...string) (result map[string]any, logged string) { - t.Helper() - var jsonOut, logOut bytes.Buffer - logger := clientlog.NewLoggerWithFlags(clientlog.INFO, &logOut, 0) - logger.SetOutputWriter(&jsonOut) - prevLogger := clientlog.GetLogger() - t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) - clientlog.SetLogger(logger) - - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run(append([]string{"cmd"}, args...))) - require.NoError(t, runErr) - - require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON") - return result, logOut.String() -} - -func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { - result, logged := runSearchJSON(t, "--limit", "1", "") - assert.Equal(t, float64(10), result["total_matches"], "stub has exactly 10 operations") - assert.Equal(t, true, result["truncated"]) - assert.Len(t, result["matches"], 1) - assert.Contains(t, logged, "of 10", "truncation warning should mention the full match count") -} - -func TestRunSearchCmd_NoTruncationFieldsFalse(t *testing.T) { - result, logged := runSearchJSON(t, "") - assert.Equal(t, float64(10), result["total_matches"]) - assert.Equal(t, false, result["truncated"]) - assert.Len(t, result["matches"], 10) - assert.Empty(t, logged, "no truncation warning expected when everything fits under the limit") -} - -// TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable guards the QA-driven -// requirement: the warning must go to stderr only, never into the stdOut -// writer carrying the table -- otherwise it would corrupt/clutter the table -// (or, for JSON, break parseability). -func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) - require.NoError(t, runErr) - // header + exactly one data row -- unchanged by the new warning path. - lineCount := 0 - for _, b := range stdOut.Bytes() { - if b == '\n' { - lineCount++ - } - } - assert.Equal(t, 2, lineCount, "the stdOut table must still be exactly header + one row") - assert.NotContains(t, stdOut.String(), "increase --limit", "the warning text must not appear in the table's stdOut writer") + require.Error(t, runErr) + assert.Contains(t, runErr.Error(), `"stub"`) + assert.Empty(t, stdOut.String()) } func TestRunSearchCmd_WrongNumberOfArguments(t *testing.T) { From 22cbc352d08f5ff45dc0ffea5b7af7e119ad87b8 Mon Sep 17 00:00:00 2001 From: remib Date: Wed, 2 Sep 2026 08:54:03 +0200 Subject: [PATCH 2/5] JGC-540 - Add full-bundle integration tests for api docs Restore search/describe command coverage behind //go:build full for the release gate; stub builds keep fail-fast tests only. Co-authored-by: Cursor --- general/api/docs_cmd_test_helpers_test.go | 89 ++++++++++++++++ general/api/docs_describe_full_test.go | 79 ++++++++++++++ general/api/docs_describe_test.go | 16 --- general/api/docs_search_full_test.go | 119 ++++++++++++++++++++++ general/api/docs_search_test.go | 20 ---- 5 files changed, 287 insertions(+), 36 deletions(-) create mode 100644 general/api/docs_cmd_test_helpers_test.go create mode 100644 general/api/docs_describe_full_test.go create mode 100644 general/api/docs_search_full_test.go diff --git a/general/api/docs_cmd_test_helpers_test.go b/general/api/docs_cmd_test_helpers_test.go new file mode 100644 index 000000000..26c61053b --- /dev/null +++ b/general/api/docs_cmd_test_helpers_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "bytes" + "encoding/json" + "testing" + + clientlog "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +// newSearchApp builds a minimal cli.App exercising runSearchCmd exactly like +// the real "search" subcommand's flag set, without going through main.go's +// full command tree. +func newSearchApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { + app := cli.NewApp() + app.Flags = []cli.Flag{ + cli.StringFlag{Name: flagTag}, + cli.StringFlag{Name: flagMethod}, + cli.IntFlag{Name: flagLimit, Value: defaultLimit}, + cli.StringFlag{Name: "format"}, + } + app.Action = func(c *cli.Context) error { + *capturedErr = runSearchCmd(c, stdOut) + return nil + } + return app +} + +// newDescribeApp builds a minimal cli.App exercising runDescribeCmd exactly +// like the real "describe" subcommand's flag set. +func newDescribeApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { + app := cli.NewApp() + app.Flags = []cli.Flag{ + cli.StringFlag{Name: "format"}, + } + app.Action = func(c *cli.Context) error { + *capturedErr = runDescribeCmd(c, stdOut) + return nil + } + return app +} + +// runSearchJSON runs the search app with JSON output (the default) and returns +// the parsed result body plus whatever landed on the logger's Warn/Info/Error +// channel. +func runSearchJSON(t *testing.T, args ...string) (result map[string]any, logged string) { + t.Helper() + var jsonOut, logOut bytes.Buffer + logger := clientlog.NewLoggerWithFlags(clientlog.INFO, &logOut, 0) + logger.SetOutputWriter(&jsonOut) + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(logger) + + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run(append([]string{"cmd"}, args...))) + require.NoError(t, runErr) + + require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON") + return result, logOut.String() +} + +// runDescribeJSON runs the describe app with JSON output (the default) and +// returns the parsed result body. +func runDescribeJSON(t *testing.T, method, path string) map[string]any { + t.Helper() + var jsonOut, logOut bytes.Buffer + logger := clientlog.NewLoggerWithFlags(clientlog.INFO, &logOut, 0) + logger.SetOutputWriter(&jsonOut) + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(logger) + + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", method, path})) + require.NoError(t, runErr) + + var result map[string]any + require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON") + return result +} diff --git a/general/api/docs_describe_full_test.go b/general/api/docs_describe_full_test.go new file mode 100644 index 000000000..59d5734cb --- /dev/null +++ b/general/api/docs_describe_full_test.go @@ -0,0 +1,79 @@ +//go:build full + +// Integration tests for `jf api docs describe` against the embedded full +// OpenAPI bundle (populated from rdme-admin at release time). These only run +// under `go test -tags full`, same gate as docs/api-spec/parser_full_test.go. + +package api + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunDescribeCmd_KnownOperation(t *testing.T) { + result := runDescribeJSON(t, "GET", "/access/api/v2/users") + assert.Equal(t, "GET", result["method"]) + assert.Equal(t, "/access/api/v2/users", result["path"]) + assert.Equal(t, "full", result["spec_bundle"]) + assert.NotEmpty(t, result["spec_version"]) + assert.NotEmpty(t, result["jf_api"]) +} + +func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { + result := runDescribeJSON(t, "get", "/access/api/v2/users") + assert.Equal(t, "GET", result["method"]) +} + +func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { + result := runDescribeJSON(t, "GET", "access/api/v2/users") + assert.Equal(t, "/access/api/v2/users", result["path"]) +} + +func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "GET", "/not/a/real/path"})) + require.Error(t, runErr) + assert.Contains(t, runErr.Error(), "no operation found") + assert.NotContains(t, runErr.Error(), `"stub"`) +} + +func TestRunDescribeCmd_TableOutput(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "POST", "/access/api/v2/users"})) + require.NoError(t, runErr) + assert.Contains(t, stdOut.String(), "METHOD") + assert.Contains(t, stdOut.String(), "POST") + assert.Contains(t, stdOut.String(), "JF API") +} + +// TestSearchThenDescribe_EndToEnd guards the intended agent flow on a full +// bundle: search → describe must agree on method/path/jf_api for the top hit. +func TestSearchThenDescribe_EndToEnd(t *testing.T) { + result, _ := runSearchJSON(t, "user") + matches, ok := result["matches"].([]any) + require.True(t, ok) + require.NotEmpty(t, matches) + + top, ok := matches[0].(map[string]any) + require.True(t, ok) + method, _ := top["method"].(string) + path, _ := top["path"].(string) + jfApi, _ := top["jf_api"].(string) + require.NotEmpty(t, method) + require.NotEmpty(t, path) + + described := runDescribeJSON(t, method, path) + assert.Equal(t, method, described["method"]) + assert.Equal(t, path, described["path"]) + assert.Equal(t, jfApi, described["jf_api"]) +} diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go index f8e1e2594..6dcf1e01d 100644 --- a/general/api/docs_describe_test.go +++ b/general/api/docs_describe_test.go @@ -14,7 +14,6 @@ import ( apispec "github.com/jfrog/jfrog-cli/docs/api-spec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli" ) func TestNormalizeApiPath(t *testing.T) { @@ -32,21 +31,6 @@ func TestFormatResponses(t *testing.T) { })) } -// newDescribeApp builds a minimal cli.App exercising runDescribeCmd exactly -// like the real "describe" subcommand's flag set -- same technique as -// newSearchApp in docs_search_test.go. -func newDescribeApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { - app := cli.NewApp() - app.Flags = []cli.Flag{ - cli.StringFlag{Name: "format"}, - } - app.Action = func(c *cli.Context) error { - *capturedErr = runDescribeCmd(c, stdOut) - return nil - } - return app -} - func TestRunDescribeCmd_StubBundleFails(t *testing.T) { var stdOut bytes.Buffer var runErr error diff --git a/general/api/docs_search_full_test.go b/general/api/docs_search_full_test.go new file mode 100644 index 000000000..75845a426 --- /dev/null +++ b/general/api/docs_search_full_test.go @@ -0,0 +1,119 @@ +//go:build full + +// Integration tests for `jf api docs search` against the embedded full +// OpenAPI bundle (populated from rdme-admin at release time). These only run +// under `go test -tags full`, same gate as docs/api-spec/parser_full_test.go. + +package api + +import ( + "bytes" + "strconv" + "testing" + + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fullOps(t *testing.T) []apispec.Operation { + t.Helper() + ops, err := apispec.Operations() + require.NoError(t, err) + require.NotEmpty(t, ops, "full bundle must be populated before running -tags full tests") + return ops +} + +func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { + result, _ := runSearchJSON(t, "permission") + assert.Equal(t, "full", result["spec_bundle"]) + assert.NotEmpty(t, result["spec_version"]) + matches, ok := result["matches"].([]any) + require.True(t, ok) + assert.NotEmpty(t, matches, "full bundle should match 'permission'") +} + +func TestRunSearchCmd_TableOutput(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "permission"})) + require.NoError(t, runErr) + assert.Contains(t, stdOut.String(), "METHOD") + assert.NotContains(t, stdOut.String(), "stub") +} + +func TestRunSearchCmd_EmptyResultStillReportsFullBundle(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "zzzznotreal"})) + require.NoError(t, runErr, "empty results must not be treated as a command failure") + assert.Contains(t, stdOut.String(), "spec_bundle=full") +} + +func TestRunSearchCmd_LimitTruncates(t *testing.T) { + fullOps(t) + + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) + require.NoError(t, runErr) + + lineCount := 0 + for _, b := range stdOut.Bytes() { + if b == '\n' { + lineCount++ + } + } + assert.Equal(t, 2, lineCount, "expected a header row plus exactly one match row") +} + +func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { + ops := fullOps(t) + if len(ops) <= 1 { + t.Skip("truncation requires more than one operation in the full bundle") + } + + result, logged := runSearchJSON(t, "--limit", "1", "") + assert.Equal(t, float64(len(ops)), result["total_matches"]) + assert.Equal(t, true, result["truncated"]) + assert.Len(t, result["matches"], 1) + assert.Contains(t, logged, "of ", "truncation warning should mention the full match count") +} + +func TestRunSearchCmd_NoTruncationWhenUnderLimit(t *testing.T) { + ops := fullOps(t) + + result, logged := runSearchJSON(t, "--limit", strconv.Itoa(len(ops)+10), "") + assert.Equal(t, float64(len(ops)), result["total_matches"]) + assert.Equal(t, false, result["truncated"]) + assert.Len(t, result["matches"], len(ops)) + assert.Empty(t, logged, "no truncation warning expected when everything fits under the limit") +} + +// TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable guards that the +// truncation warning goes to stderr only, never into the table stdOut writer. +func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { + fullOps(t) + + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) + require.NoError(t, runErr) + + lineCount := 0 + for _, b := range stdOut.Bytes() { + if b == '\n' { + lineCount++ + } + } + assert.Equal(t, 2, lineCount, "the stdOut table must still be exactly header + one row") + assert.NotContains(t, stdOut.String(), "increase --limit") +} diff --git a/general/api/docs_search_test.go b/general/api/docs_search_test.go index 7c086658b..bb1a541ac 100644 --- a/general/api/docs_search_test.go +++ b/general/api/docs_search_test.go @@ -15,7 +15,6 @@ import ( apispec "github.com/jfrog/jfrog-cli/docs/api-spec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli" ) func stubOps(t *testing.T) []apispec.Operation { @@ -173,25 +172,6 @@ func TestHasTag(t *testing.T) { assert.False(t, hasTag([]string{"Users"}, "workers")) } -// newSearchApp builds a minimal cli.App exercising runSearchCmd exactly like -// the real "search" subcommand's flag set, without going through main.go's -// full command tree -- same technique as TestResolveRequestBody in -// cli_test.go. -func newSearchApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { - app := cli.NewApp() - app.Flags = []cli.Flag{ - cli.StringFlag{Name: flagTag}, - cli.StringFlag{Name: flagMethod}, - cli.IntFlag{Name: flagLimit, Value: defaultLimit}, - cli.StringFlag{Name: "format"}, - } - app.Action = func(c *cli.Context) error { - *capturedErr = runSearchCmd(c, stdOut) - return nil - } - return app -} - // TestRunSearchCmd_StubBundleFails verifies that source/stub builds refuse to // serve api docs search rather than returning a misleading partial catalog. func TestRunSearchCmd_StubBundleFails(t *testing.T) { From ad498585a7c911b18afe9e0390e91ea3e4b91d26 Mon Sep 17 00:00:00 2001 From: remib Date: Wed, 2 Sep 2026 08:57:19 +0200 Subject: [PATCH 3/5] JGC-540 - Gate stub api docs failure behind env flag Default behavior is unchanged: stub builds still serve api docs search/describe for dev/OSS tests. Set JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=true to fail fast with a clear error instead of returning a partial catalog. Co-authored-by: Cursor --- docs/general/apidocsdescribe/help.go | 4 +- docs/general/apidocssearch/help.go | 4 +- general/api/docs_bundle.go | 17 +++- general/api/docs_describe.go | 2 +- general/api/docs_describe_full_test.go | 79 ---------------- general/api/docs_describe_test.go | 98 +++++++++++++++++--- general/api/docs_search.go | 2 +- general/api/docs_search_full_test.go | 119 ------------------------- general/api/docs_search_test.go | 117 ++++++++++++++++++++++-- 9 files changed, 219 insertions(+), 223 deletions(-) delete mode 100644 general/api/docs_describe_full_test.go delete mode 100644 general/api/docs_search_full_test.go diff --git a/docs/general/apidocsdescribe/help.go b/docs/general/apidocsdescribe/help.go index 2a2f8b34f..d7df56f65 100644 --- a/docs/general/apidocsdescribe/help.go +++ b/docs/general/apidocsdescribe/help.go @@ -48,10 +48,10 @@ Common patterns: $ jf api docs describe DELETE /worker/api/v1/workers/{workerKey} Gotchas: -- Requires a binary built with the full OpenAPI bundle (official releases from https://install-cli.jfrog.io). Source/Homebrew builds exit with an error instead of returning partial results. +- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface — an unresolved lookup names spec_bundle so you know whether that's the likely cause. Set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=true to fail fast on stub builds instead. - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - path must match the catalog exactly, including any literal {param} placeholders (e.g. "{workerKey}", not a real key) — copy it verbatim from 'jf api docs search' results rather than guessing. -- Not found (wrong method or path) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. +- Not found (wrong method, wrong path, or the stub bundle lacks the operation) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. - request_body's "example" field is only present when the underlying spec declares one; its absence doesn't mean the operation has no valid payload — check "properties" either way. - A request body property that is itself a nested object is reported by its type name (e.g. "PermissionResource") or "object" rather than being recursively flattened — only top-level fields are listed. diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go index a8f14794c..e55698fe9 100644 --- a/docs/general/apidocssearch/help.go +++ b/docs/general/apidocssearch/help.go @@ -27,7 +27,7 @@ EXAMPLES $ jf api docs search user --format table OUTPUT - JSON by default (this command exists primarily for agent consumption); pass --format table for a human-readable table instead. Each match includes the operation's method, path, summary, tags, a relevance score, and a "jf_api" field with a ready-to-run 'jf api' invocation for that operation. When the operation takes path/query parameters, they're listed under "parameters" (required ones marked). When it takes a JSON request body, its top-level fields (name, type, required, description, default) are listed under "request_body", and "jf_api" already includes a minimal -d '{...}' skeleton covering just the required fields — fill in real values before running it. Table view shows this as compact PARAMS/BODY columns ("*" marks a required field). An empty result set still reports which spec bundle was searched (spec_bundle). Exits 0 even when no matches are found. "total_matches" and "truncated" report the full match count and whether --limit cut it down; when truncated, a warning is also printed to stderr (not stdout, so it never corrupts the JSON body or table). Requires a binary built with the full OpenAPI bundle (official releases from https://install-cli.jfrog.io); source/Homebrew builds exit with an error instead.` + JSON by default (this command exists primarily for agent consumption); pass --format table for a human-readable table instead. Each match includes the operation's method, path, summary, tags, a relevance score, and a "jf_api" field with a ready-to-run 'jf api' invocation for that operation. When the operation takes path/query parameters, they're listed under "parameters" (required ones marked). When it takes a JSON request body, its top-level fields (name, type, required, description, default) are listed under "request_body", and "jf_api" already includes a minimal -d '{...}' skeleton covering just the required fields — fill in real values before running it. Table view shows this as compact PARAMS/BODY columns ("*" marks a required field). An empty result set still reports which spec bundle was searched (spec_bundle) — a "stub" bundle may simply be missing the operation. Exits 0 even when no matches are found. "total_matches" and "truncated" report the full match count and whether --limit cut it down; when truncated, a warning is also printed to stderr (not stdout, so it never corrupts the JSON body or table).` } func GetAIDescription() string { @@ -46,7 +46,7 @@ Common patterns: $ jf api docs search repository --limit 3 --format json Gotchas: -- Requires a binary built with the full OpenAPI bundle (official releases from https://install-cli.jfrog.io). Source/Homebrew builds exit with an error instead of returning partial results. +- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface. An empty match list includes spec_bundle so you know whether that's the likely cause. Set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=true to fail fast on stub builds instead. - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - Filters (--tag, --method) are hard excludes, applied before ranking/scoring. - A query with no contains-match anywhere falls back to fuzzy (typo-tolerant) matching, gated by a similarity floor to avoid coincidental false positives (e.g. "evidence" vs "environments"). Advanced: override the floor (0-1, default 0.6) with $JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN. diff --git a/general/api/docs_bundle.go b/general/api/docs_bundle.go index 237946402..737b8c591 100644 --- a/general/api/docs_bundle.go +++ b/general/api/docs_bundle.go @@ -1,10 +1,25 @@ package api import ( + "os" + "strings" + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" "github.com/jfrog/jfrog-client-go/utils/errorutils" ) -func requireFullApiDocsBundle() error { +// envRequireFullBundle, when set to "true", makes `jf api docs search` and +// `jf api docs describe` fail on binaries that embed the dev "stub" OpenAPI +// bundle instead of returning a partial catalog. +const envRequireFullBundle = "JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE" + +func apiDocsRequireFullBundle() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(envRequireFullBundle)), "true") +} + +func maybeRequireFullApiDocsBundle() error { + if !apiDocsRequireFullBundle() { + return nil + } return errorutils.CheckError(apispec.RequireFullBundle()) } diff --git a/general/api/docs_describe.go b/general/api/docs_describe.go index 0883df679..0f4057148 100644 --- a/general/api/docs_describe.go +++ b/general/api/docs_describe.go @@ -50,7 +50,7 @@ func runDescribeCmd(c *cli.Context, stdOut io.Writer) error { } method := c.Args().Get(0) path := normalizeApiPath(c.Args().Get(1)) - if err := requireFullApiDocsBundle(); err != nil { + if err := maybeRequireFullApiDocsBundle(); err != nil { return err } diff --git a/general/api/docs_describe_full_test.go b/general/api/docs_describe_full_test.go deleted file mode 100644 index 59d5734cb..000000000 --- a/general/api/docs_describe_full_test.go +++ /dev/null @@ -1,79 +0,0 @@ -//go:build full - -// Integration tests for `jf api docs describe` against the embedded full -// OpenAPI bundle (populated from rdme-admin at release time). These only run -// under `go test -tags full`, same gate as docs/api-spec/parser_full_test.go. - -package api - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRunDescribeCmd_KnownOperation(t *testing.T) { - result := runDescribeJSON(t, "GET", "/access/api/v2/users") - assert.Equal(t, "GET", result["method"]) - assert.Equal(t, "/access/api/v2/users", result["path"]) - assert.Equal(t, "full", result["spec_bundle"]) - assert.NotEmpty(t, result["spec_version"]) - assert.NotEmpty(t, result["jf_api"]) -} - -func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { - result := runDescribeJSON(t, "get", "/access/api/v2/users") - assert.Equal(t, "GET", result["method"]) -} - -func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { - result := runDescribeJSON(t, "GET", "access/api/v2/users") - assert.Equal(t, "/access/api/v2/users", result["path"]) -} - -func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newDescribeApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "GET", "/not/a/real/path"})) - require.Error(t, runErr) - assert.Contains(t, runErr.Error(), "no operation found") - assert.NotContains(t, runErr.Error(), `"stub"`) -} - -func TestRunDescribeCmd_TableOutput(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newDescribeApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "POST", "/access/api/v2/users"})) - require.NoError(t, runErr) - assert.Contains(t, stdOut.String(), "METHOD") - assert.Contains(t, stdOut.String(), "POST") - assert.Contains(t, stdOut.String(), "JF API") -} - -// TestSearchThenDescribe_EndToEnd guards the intended agent flow on a full -// bundle: search → describe must agree on method/path/jf_api for the top hit. -func TestSearchThenDescribe_EndToEnd(t *testing.T) { - result, _ := runSearchJSON(t, "user") - matches, ok := result["matches"].([]any) - require.True(t, ok) - require.NotEmpty(t, matches) - - top, ok := matches[0].(map[string]any) - require.True(t, ok) - method, _ := top["method"].(string) - path, _ := top["path"].(string) - jfApi, _ := top["jf_api"].(string) - require.NotEmpty(t, method) - require.NotEmpty(t, path) - - described := runDescribeJSON(t, method, path) - assert.Equal(t, method, described["method"]) - assert.Equal(t, path, described["path"]) - assert.Equal(t, jfApi, described["jf_api"]) -} diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go index 6dcf1e01d..8210b98dd 100644 --- a/general/api/docs_describe_test.go +++ b/general/api/docs_describe_test.go @@ -9,9 +9,11 @@ package api import ( "bytes" + "encoding/json" "testing" apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + clientlog "github.com/jfrog/jfrog-client-go/utils/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,16 +33,44 @@ func TestFormatResponses(t *testing.T) { })) } -func TestRunDescribeCmd_StubBundleFails(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newDescribeApp(&stdOut, &runErr) +func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { + result := runDescribeJSON(t, "GET", "/access/api/v2/users") + assert.Equal(t, "GET", result["method"]) + assert.Equal(t, "/access/api/v2/users", result["path"]) + assert.Equal(t, "stub", result["spec_bundle"]) + assert.NotEmpty(t, result["parameters"]) + assert.Nil(t, result["request_body"]) + assert.NotEmpty(t, result["responses"]) + assert.Equal(t, "jf api /access/api/v2/users", result["jf_api"]) +} - require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"})) - require.Error(t, runErr) - assert.Contains(t, runErr.Error(), `"stub"`) - assert.Contains(t, runErr.Error(), "install-cli.jfrog.io") - assert.Empty(t, stdOut.String()) +func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { + result := runDescribeJSON(t, "POST", "/access/api/v2/users") + assert.Equal(t, "POST", result["method"]) + + requestBody, ok := result["request_body"].(map[string]any) + require.True(t, ok, "createUser should carry a request_body") + required, ok := requestBody["required"].(bool) + require.True(t, ok) + assert.True(t, required) + properties, ok := requestBody["properties"].([]any) + require.True(t, ok) + assert.NotEmpty(t, properties) + + jfApi, ok := result["jf_api"].(string) + require.True(t, ok) + assert.Contains(t, jfApi, "-X POST") + assert.Contains(t, jfApi, "-d '") +} + +func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { + result := runDescribeJSON(t, "get", "/access/api/v2/users") + assert.Equal(t, "GET", result["method"]) +} + +func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { + result := runDescribeJSON(t, "GET", "access/api/v2/users") + assert.Equal(t, "/access/api/v2/users", result["path"]) } func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { @@ -50,7 +80,8 @@ func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { require.NoError(t, app.Run([]string{"cmd", "GET", "/not/a/real/path"})) require.Error(t, runErr) - assert.Contains(t, runErr.Error(), `"stub"`) + assert.Contains(t, runErr.Error(), "spec_bundle") + assert.Contains(t, runErr.Error(), "docs search") } func TestRunDescribeCmd_WrongNumberOfArguments(t *testing.T) { @@ -73,7 +104,54 @@ func TestRunDescribeCmd_TableOutput(t *testing.T) { app := newDescribeApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "--format", "table", "POST", "/access/api/v2/users"})) + require.NoError(t, runErr) + assert.Contains(t, stdOut.String(), "METHOD") + assert.Contains(t, stdOut.String(), "POST") + assert.Contains(t, stdOut.String(), "REQUEST BODY") + assert.Contains(t, stdOut.String(), "RESPONSES") + assert.Contains(t, stdOut.String(), "JF API") +} + +func TestRunDescribeCmd_DefaultsToJSON(t *testing.T) { + var out bytes.Buffer + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(clientlog.NewLoggerWithFlags(clientlog.INFO, &out, 0)) + + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"})) + require.NoError(t, runErr) + + var result map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &result), "default output should be parseable JSON") + assert.Equal(t, "stub", result["spec_bundle"]) + assert.Empty(t, stdOut.String(), "JSON goes through the logger's Output channel, not the stdOut writer") +} + +func TestRunDescribeCmd_RequireFullBundleFailsOnStub(t *testing.T) { + t.Setenv(envRequireFullBundle, "true") + + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"})) require.Error(t, runErr) assert.Contains(t, runErr.Error(), `"stub"`) + assert.Contains(t, runErr.Error(), "install-cli.jfrog.io") assert.Empty(t, stdOut.String()) } + +func TestSearchThenDescribe_EndToEnd(t *testing.T) { + matches := filterAndScore(stubOps(t), "user", "", "") + require.NotEmpty(t, matches) + top := matches[0] + + result := runDescribeJSON(t, top.Method, top.Path) + assert.Equal(t, top.Method, result["method"]) + assert.Equal(t, top.Path, result["path"]) + assert.Equal(t, top.JfApi, result["jf_api"]) +} diff --git a/general/api/docs_search.go b/general/api/docs_search.go index 1eaf5df2a..13643c271 100644 --- a/general/api/docs_search.go +++ b/general/api/docs_search.go @@ -109,7 +109,7 @@ func runSearchCmd(c *cli.Context, stdOut io.Writer) error { if limit <= 0 { limit = defaultLimit } - if err := requireFullApiDocsBundle(); err != nil { + if err := maybeRequireFullApiDocsBundle(); err != nil { return err } diff --git a/general/api/docs_search_full_test.go b/general/api/docs_search_full_test.go deleted file mode 100644 index 75845a426..000000000 --- a/general/api/docs_search_full_test.go +++ /dev/null @@ -1,119 +0,0 @@ -//go:build full - -// Integration tests for `jf api docs search` against the embedded full -// OpenAPI bundle (populated from rdme-admin at release time). These only run -// under `go test -tags full`, same gate as docs/api-spec/parser_full_test.go. - -package api - -import ( - "bytes" - "strconv" - "testing" - - apispec "github.com/jfrog/jfrog-cli/docs/api-spec" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func fullOps(t *testing.T) []apispec.Operation { - t.Helper() - ops, err := apispec.Operations() - require.NoError(t, err) - require.NotEmpty(t, ops, "full bundle must be populated before running -tags full tests") - return ops -} - -func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { - result, _ := runSearchJSON(t, "permission") - assert.Equal(t, "full", result["spec_bundle"]) - assert.NotEmpty(t, result["spec_version"]) - matches, ok := result["matches"].([]any) - require.True(t, ok) - assert.NotEmpty(t, matches, "full bundle should match 'permission'") -} - -func TestRunSearchCmd_TableOutput(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "permission"})) - require.NoError(t, runErr) - assert.Contains(t, stdOut.String(), "METHOD") - assert.NotContains(t, stdOut.String(), "stub") -} - -func TestRunSearchCmd_EmptyResultStillReportsFullBundle(t *testing.T) { - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "zzzznotreal"})) - require.NoError(t, runErr, "empty results must not be treated as a command failure") - assert.Contains(t, stdOut.String(), "spec_bundle=full") -} - -func TestRunSearchCmd_LimitTruncates(t *testing.T) { - fullOps(t) - - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) - require.NoError(t, runErr) - - lineCount := 0 - for _, b := range stdOut.Bytes() { - if b == '\n' { - lineCount++ - } - } - assert.Equal(t, 2, lineCount, "expected a header row plus exactly one match row") -} - -func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { - ops := fullOps(t) - if len(ops) <= 1 { - t.Skip("truncation requires more than one operation in the full bundle") - } - - result, logged := runSearchJSON(t, "--limit", "1", "") - assert.Equal(t, float64(len(ops)), result["total_matches"]) - assert.Equal(t, true, result["truncated"]) - assert.Len(t, result["matches"], 1) - assert.Contains(t, logged, "of ", "truncation warning should mention the full match count") -} - -func TestRunSearchCmd_NoTruncationWhenUnderLimit(t *testing.T) { - ops := fullOps(t) - - result, logged := runSearchJSON(t, "--limit", strconv.Itoa(len(ops)+10), "") - assert.Equal(t, float64(len(ops)), result["total_matches"]) - assert.Equal(t, false, result["truncated"]) - assert.Len(t, result["matches"], len(ops)) - assert.Empty(t, logged, "no truncation warning expected when everything fits under the limit") -} - -// TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable guards that the -// truncation warning goes to stderr only, never into the table stdOut writer. -func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { - fullOps(t) - - var stdOut bytes.Buffer - var runErr error - app := newSearchApp(&stdOut, &runErr) - - require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) - require.NoError(t, runErr) - - lineCount := 0 - for _, b := range stdOut.Bytes() { - if b == '\n' { - lineCount++ - } - } - assert.Equal(t, 2, lineCount, "the stdOut table must still be exactly header + one row") - assert.NotContains(t, stdOut.String(), "increase --limit") -} diff --git a/general/api/docs_search_test.go b/general/api/docs_search_test.go index bb1a541ac..99250863d 100644 --- a/general/api/docs_search_test.go +++ b/general/api/docs_search_test.go @@ -9,10 +9,12 @@ package api import ( "bytes" + "encoding/json" "slices" "testing" apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + clientlog "github.com/jfrog/jfrog-client-go/utils/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -172,28 +174,127 @@ func TestHasTag(t *testing.T) { assert.False(t, hasTag([]string{"Users"}, "workers")) } -// TestRunSearchCmd_StubBundleFails verifies that source/stub builds refuse to -// serve api docs search rather than returning a misleading partial catalog. -func TestRunSearchCmd_StubBundleFails(t *testing.T) { +func TestApiDocsRequireFullBundle_DefaultOff(t *testing.T) { + t.Setenv(envRequireFullBundle, "") + assert.False(t, apiDocsRequireFullBundle()) +} + +func TestApiDocsRequireFullBundle_Enabled(t *testing.T) { + t.Setenv(envRequireFullBundle, "true") + assert.True(t, apiDocsRequireFullBundle()) +} + +// TestRunSearchCmd_DefaultsToJSON verifies JSON is the default output format +// when --format is omitted entirely -- this command exists primarily for +// agent consumption, unlike most other jf commands whose JSON default is +// gated on --ai-help/$JFROG_CLI_AI_HELP. Swaps the shared client logger since +// the JSON path writes via its Output channel, same technique as +// TestApiJSONErrorMode_EmitsJSONOnStdout in cli_test.go. +func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { + var out bytes.Buffer + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(clientlog.NewLoggerWithFlags(clientlog.INFO, &out, 0)) + var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "user"})) - require.Error(t, runErr) - assert.Contains(t, runErr.Error(), `"stub"`) - assert.Contains(t, runErr.Error(), "install-cli.jfrog.io") - assert.Empty(t, stdOut.String()) + require.NoError(t, runErr) + + var result map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &result), "default output should be parseable JSON") + assert.Equal(t, "stub", result["spec_bundle"]) + assert.Empty(t, stdOut.String(), "JSON goes through the logger's Output channel, not the stdOut writer") } -func TestRunSearchCmd_StubBundleFailsTableFormat(t *testing.T) { +func TestRunSearchCmd_TableOutput(t *testing.T) { var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "--format", "table", "user"})) + require.NoError(t, runErr) + assert.Contains(t, stdOut.String(), "METHOD") + assert.Contains(t, stdOut.String(), "/access/api/v2/users") +} + +func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "zzzznotreal"})) + require.NoError(t, runErr, "empty results must not be treated as a command failure") + assert.Contains(t, stdOut.String(), "spec_bundle=") + assert.Contains(t, stdOut.String(), "stub") +} + +func TestRunSearchCmd_LimitTruncates(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) + require.NoError(t, runErr) + lineCount := 0 + for _, b := range stdOut.Bytes() { + if b == '\n' { + lineCount++ + } + } + assert.Equal(t, 2, lineCount, "expected a header row plus exactly one match row") +} + +func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { + result, logged := runSearchJSON(t, "--limit", "1", "") + assert.Equal(t, float64(10), result["total_matches"], "stub has exactly 10 operations") + assert.Equal(t, true, result["truncated"]) + assert.Len(t, result["matches"], 1) + assert.Contains(t, logged, "of 10", "truncation warning should mention the full match count") +} + +func TestRunSearchCmd_NoTruncationFieldsFalse(t *testing.T) { + result, logged := runSearchJSON(t, "") + assert.Equal(t, float64(10), result["total_matches"]) + assert.Equal(t, false, result["truncated"]) + assert.Len(t, result["matches"], 10) + assert.Empty(t, logged, "no truncation warning expected when everything fits under the limit") +} + +// TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable guards the QA-driven +// requirement: the warning must go to stderr only, never into the stdOut +// writer carrying the table -- otherwise it would corrupt/clutter the table +// (or, for JSON, break parseability). +func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) + require.NoError(t, runErr) + lineCount := 0 + for _, b := range stdOut.Bytes() { + if b == '\n' { + lineCount++ + } + } + assert.Equal(t, 2, lineCount, "the stdOut table must still be exactly header + one row") + assert.NotContains(t, stdOut.String(), "increase --limit", "the warning text must not appear in the table's stdOut writer") +} + +func TestRunSearchCmd_RequireFullBundleFailsOnStub(t *testing.T) { + t.Setenv(envRequireFullBundle, "true") + + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "user"})) require.Error(t, runErr) assert.Contains(t, runErr.Error(), `"stub"`) + assert.Contains(t, runErr.Error(), "install-cli.jfrog.io") assert.Empty(t, stdOut.String()) } From 88548ab4bb1d2738203b03b9066895dfec0ed2c6 Mon Sep 17 00:00:00 2001 From: remib Date: Wed, 2 Sep 2026 09:00:26 +0200 Subject: [PATCH 4/5] JGC-540 - Default api docs full-bundle gate to enabled Require the full OpenAPI bundle unless JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false is set explicitly. Co-authored-by: Cursor --- docs/general/apidocsdescribe/help.go | 2 +- docs/general/apidocssearch/help.go | 2 +- general/api/docs_bundle.go | 7 ++++--- general/api/docs_describe_test.go | 10 ++++++++-- general/api/docs_search_test.go | 24 +++++++++++++++++------- 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/docs/general/apidocsdescribe/help.go b/docs/general/apidocsdescribe/help.go index d7df56f65..d6bf7f8b5 100644 --- a/docs/general/apidocsdescribe/help.go +++ b/docs/general/apidocsdescribe/help.go @@ -48,7 +48,7 @@ Common patterns: $ jf api docs describe DELETE /worker/api/v1/workers/{workerKey} Gotchas: -- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface — an unresolved lookup names spec_bundle so you know whether that's the likely cause. Set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=true to fail fast on stub builds instead. +- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, `jf api docs describe` fails fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only). - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - path must match the catalog exactly, including any literal {param} placeholders (e.g. "{workerKey}", not a real key) — copy it verbatim from 'jf api docs search' results rather than guessing. - Not found (wrong method, wrong path, or the stub bundle lacks the operation) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go index e55698fe9..bf30a8ea3 100644 --- a/docs/general/apidocssearch/help.go +++ b/docs/general/apidocssearch/help.go @@ -46,7 +46,7 @@ Common patterns: $ jf api docs search repository --limit 3 --format json Gotchas: -- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface. An empty match list includes spec_bundle so you know whether that's the likely cause. Set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=true to fail fast on stub builds instead. +- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, `jf api docs search` and `jf api docs describe` fail fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only). - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - Filters (--tag, --method) are hard excludes, applied before ranking/scoring. - A query with no contains-match anywhere falls back to fuzzy (typo-tolerant) matching, gated by a similarity floor to avoid coincidental false positives (e.g. "evidence" vs "environments"). Advanced: override the floor (0-1, default 0.6) with $JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN. diff --git a/general/api/docs_bundle.go b/general/api/docs_bundle.go index 737b8c591..be55eb9db 100644 --- a/general/api/docs_bundle.go +++ b/general/api/docs_bundle.go @@ -8,13 +8,14 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" ) -// envRequireFullBundle, when set to "true", makes `jf api docs search` and +// envRequireFullBundle controls whether `jf api docs search` and // `jf api docs describe` fail on binaries that embed the dev "stub" OpenAPI -// bundle instead of returning a partial catalog. +// bundle. Enabled by default; set to "false" to allow the partial catalog. const envRequireFullBundle = "JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE" func apiDocsRequireFullBundle() bool { - return strings.EqualFold(strings.TrimSpace(os.Getenv(envRequireFullBundle)), "true") + v := strings.ToLower(strings.TrimSpace(os.Getenv(envRequireFullBundle))) + return v != "false" } func maybeRequireFullApiDocsBundle() error { diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go index 8210b98dd..13068370b 100644 --- a/general/api/docs_describe_test.go +++ b/general/api/docs_describe_test.go @@ -34,6 +34,7 @@ func TestFormatResponses(t *testing.T) { } func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { + allowStubApiDocsBundle(t) result := runDescribeJSON(t, "GET", "/access/api/v2/users") assert.Equal(t, "GET", result["method"]) assert.Equal(t, "/access/api/v2/users", result["path"]) @@ -45,6 +46,7 @@ func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { } func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { + allowStubApiDocsBundle(t) result := runDescribeJSON(t, "POST", "/access/api/v2/users") assert.Equal(t, "POST", result["method"]) @@ -64,16 +66,19 @@ func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { } func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { + allowStubApiDocsBundle(t) result := runDescribeJSON(t, "get", "/access/api/v2/users") assert.Equal(t, "GET", result["method"]) } func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { + allowStubApiDocsBundle(t) result := runDescribeJSON(t, "GET", "access/api/v2/users") assert.Equal(t, "/access/api/v2/users", result["path"]) } func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newDescribeApp(&stdOut, &runErr) @@ -99,6 +104,7 @@ func TestRunDescribeCmd_WrongNumberOfArguments(t *testing.T) { } func TestRunDescribeCmd_TableOutput(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newDescribeApp(&stdOut, &runErr) @@ -113,6 +119,7 @@ func TestRunDescribeCmd_TableOutput(t *testing.T) { } func TestRunDescribeCmd_DefaultsToJSON(t *testing.T) { + allowStubApiDocsBundle(t) var out bytes.Buffer prevLogger := clientlog.GetLogger() t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) @@ -132,8 +139,6 @@ func TestRunDescribeCmd_DefaultsToJSON(t *testing.T) { } func TestRunDescribeCmd_RequireFullBundleFailsOnStub(t *testing.T) { - t.Setenv(envRequireFullBundle, "true") - var stdOut bytes.Buffer var runErr error app := newDescribeApp(&stdOut, &runErr) @@ -146,6 +151,7 @@ func TestRunDescribeCmd_RequireFullBundleFailsOnStub(t *testing.T) { } func TestSearchThenDescribe_EndToEnd(t *testing.T) { + allowStubApiDocsBundle(t) matches := filterAndScore(stubOps(t), "user", "", "") require.NotEmpty(t, matches) top := matches[0] diff --git a/general/api/docs_search_test.go b/general/api/docs_search_test.go index 99250863d..cc3f25185 100644 --- a/general/api/docs_search_test.go +++ b/general/api/docs_search_test.go @@ -174,16 +174,21 @@ func TestHasTag(t *testing.T) { assert.False(t, hasTag([]string{"Users"}, "workers")) } -func TestApiDocsRequireFullBundle_DefaultOff(t *testing.T) { - t.Setenv(envRequireFullBundle, "") - assert.False(t, apiDocsRequireFullBundle()) +func allowStubApiDocsBundle(t *testing.T) { + t.Helper() + t.Setenv(envRequireFullBundle, "false") } -func TestApiDocsRequireFullBundle_Enabled(t *testing.T) { - t.Setenv(envRequireFullBundle, "true") +func TestApiDocsRequireFullBundle_DefaultOn(t *testing.T) { + t.Setenv(envRequireFullBundle, "") assert.True(t, apiDocsRequireFullBundle()) } +func TestApiDocsRequireFullBundle_Disabled(t *testing.T) { + t.Setenv(envRequireFullBundle, "false") + assert.False(t, apiDocsRequireFullBundle()) +} + // TestRunSearchCmd_DefaultsToJSON verifies JSON is the default output format // when --format is omitted entirely -- this command exists primarily for // agent consumption, unlike most other jf commands whose JSON default is @@ -191,6 +196,7 @@ func TestApiDocsRequireFullBundle_Enabled(t *testing.T) { // the JSON path writes via its Output channel, same technique as // TestApiJSONErrorMode_EmitsJSONOnStdout in cli_test.go. func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { + allowStubApiDocsBundle(t) var out bytes.Buffer prevLogger := clientlog.GetLogger() t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) @@ -210,6 +216,7 @@ func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { } func TestRunSearchCmd_TableOutput(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) @@ -221,6 +228,7 @@ func TestRunSearchCmd_TableOutput(t *testing.T) { } func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) @@ -232,6 +240,7 @@ func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { } func TestRunSearchCmd_LimitTruncates(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) @@ -248,6 +257,7 @@ func TestRunSearchCmd_LimitTruncates(t *testing.T) { } func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { + allowStubApiDocsBundle(t) result, logged := runSearchJSON(t, "--limit", "1", "") assert.Equal(t, float64(10), result["total_matches"], "stub has exactly 10 operations") assert.Equal(t, true, result["truncated"]) @@ -256,6 +266,7 @@ func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { } func TestRunSearchCmd_NoTruncationFieldsFalse(t *testing.T) { + allowStubApiDocsBundle(t) result, logged := runSearchJSON(t, "") assert.Equal(t, float64(10), result["total_matches"]) assert.Equal(t, false, result["truncated"]) @@ -268,6 +279,7 @@ func TestRunSearchCmd_NoTruncationFieldsFalse(t *testing.T) { // writer carrying the table -- otherwise it would corrupt/clutter the table // (or, for JSON, break parseability). func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) @@ -285,8 +297,6 @@ func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { } func TestRunSearchCmd_RequireFullBundleFailsOnStub(t *testing.T) { - t.Setenv(envRequireFullBundle, "true") - var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) From e3e8f52941e136f624abc269db91f181c16f9d58 Mon Sep 17 00:00:00 2001 From: remib Date: Wed, 2 Sep 2026 11:13:50 +0200 Subject: [PATCH 5/5] JGC-540 - Fix help string syntax for CI static analysis Backticks inside raw string literals broke Go parsing in apidocs help files. Co-authored-by: Cursor --- docs/general/apidocsdescribe/help.go | 2 +- docs/general/apidocssearch/help.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/general/apidocsdescribe/help.go b/docs/general/apidocsdescribe/help.go index d6bf7f8b5..251b95969 100644 --- a/docs/general/apidocsdescribe/help.go +++ b/docs/general/apidocsdescribe/help.go @@ -48,7 +48,7 @@ Common patterns: $ jf api docs describe DELETE /worker/api/v1/workers/{workerKey} Gotchas: -- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, `jf api docs describe` fails fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only). +- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, 'jf api docs describe' fails fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only). - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - path must match the catalog exactly, including any literal {param} placeholders (e.g. "{workerKey}", not a real key) — copy it verbatim from 'jf api docs search' results rather than guessing. - Not found (wrong method, wrong path, or the stub bundle lacks the operation) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go index bf30a8ea3..cee3cba9c 100644 --- a/docs/general/apidocssearch/help.go +++ b/docs/general/apidocssearch/help.go @@ -46,7 +46,7 @@ Common patterns: $ jf api docs search repository --limit 3 --format json Gotchas: -- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, `jf api docs search` and `jf api docs describe` fail fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only). +- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, 'jf api docs search' and 'jf api docs describe' fail fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only). - Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. - Filters (--tag, --method) are hard excludes, applied before ranking/scoring. - A query with no contains-match anywhere falls back to fuzzy (typo-tolerant) matching, gated by a similarity floor to avoid coincidental false positives (e.g. "evidence" vs "environments"). Advanced: override the floor (0-1, default 0.6) with $JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN.