diff --git a/api_test.go b/api_test.go new file mode 100644 index 000000000..4d3ea87b6 --- /dev/null +++ b/api_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/jfrog/jfrog-cli/utils/tests" + clientUtils "github.com/jfrog/jfrog-client-go/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var apiCli *coreTests.JfrogCli + +func InitApiTests() { + initApiCli() +} + +func initApiTest(t *testing.T) { + if !*tests.TestApi { + t.Skip("Skipping api command test. To run add the '-test.api=true' option.") + } +} + +func initApiCli() { + if apiCli != nil { + return + } + *tests.JfrogUrl = clientUtils.AddTrailingSlashIfNeeded(*tests.JfrogUrl) + apiCli = coreTests.NewJfrogCli(execMain, "jfrog", authenticateApiCmd()) +} + +func authenticateApiCmd() string { + cred := fmt.Sprintf("--url=%s", *tests.JfrogUrl) + if *tests.JfrogAccessToken != "" { + cred += fmt.Sprintf(" --access-token=%s", *tests.JfrogAccessToken) + } else { + cred += fmt.Sprintf(" --user=%s --password=%s", *tests.JfrogUser, *tests.JfrogPassword) + } + return cred +} + +// TestApiGetArtifactoryPing verifies a GET request to the Artifactory ping endpoint returns 200 with "OK". +func TestApiGetArtifactoryPing(t *testing.T) { + initApiTest(t) + stdout, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "artifactory/api/system/ping") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) + assert.Contains(t, strings.TrimSpace(string(stdout)), "OK") +} + +// TestApiGetAccessPing verifies a GET request to the Access ping endpoint returns 200 with JSON {"status":"UP"}. +func TestApiGetAccessPing(t *testing.T) { + initApiTest(t) + stdout, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "access/api/v1/system/ping") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout, &result)) + assert.Equal(t, "UP", result["status"]) +} + +// TestApiVerbose verifies that --verbose writes request/response diagnostic lines to stderr. +func TestApiVerbose(t *testing.T) { + initApiTest(t) + stdout, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "--verbose", "artifactory/api/system/ping") + require.NoError(t, err) + stderrStr := string(stderr) + assert.Contains(t, stderrStr, "* Request to") + assert.Contains(t, stderrStr, "> GET") + assert.Contains(t, stderrStr, "* Response") + // Status code is still written as the last stderr line. + lines := strings.Split(strings.TrimSpace(stderrStr), "\n") + assert.Equal(t, "200", lines[len(lines)-1]) + assert.Contains(t, strings.TrimSpace(string(stdout)), "OK") +} + +// TestApiNotFound verifies that a 404 response causes a non-zero exit. +func TestApiNotFound(t *testing.T) { + initApiTest(t) + err := apiCli.Exec("api", "artifactory/api/nosuchendpointxxx") + assert.Error(t, err) +} + +// TestApiMethodPost verifies that --method=POST is forwarded to the server. +// Uses the AQL search endpoint, which requires POST. +func TestApiMethodPost(t *testing.T) { + initApiTest(t) + _, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "--method=POST", `--data=items.find({})`, "artifactory/api/search/aql") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) +} + +// TestApiPostWithData verifies that --data sends a request body with a POST. +// Uses the AQL search endpoint, which requires a POST body. +func TestApiPostWithData(t *testing.T) { + initApiTest(t) + stdout, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "--method=POST", `--data=items.find({})`, "artifactory/api/search/aql") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) + var result map[string]interface{} + require.NoError(t, json.Unmarshal(stdout, &result)) + _, hasResults := result["results"] + assert.True(t, hasResults) +} + +// TestApiCustomHeader verifies that a custom --header value is accepted and the request succeeds. +func TestApiCustomHeader(t *testing.T) { + initApiTest(t) + _, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "--header=X-Jfrog-Test: integration-test", "access/api/v1/system/ping") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) +} + +// TestApiMethodCaseInsensitive verifies that method names are normalized to uppercase. +func TestApiMethodCaseInsensitive(t *testing.T) { + initApiTest(t) + stdout, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "--method=get", "artifactory/api/system/ping") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) + assert.Contains(t, strings.TrimSpace(string(stdout)), "OK") +} + +// TestApiLeadingSlashInPath verifies that a path without a leading slash is handled correctly. +func TestApiLeadingSlashInPath(t *testing.T) { + initApiTest(t) + // Without leading slash — the command should normalise it internally. + stdout, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "artifactory/api/system/ping") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) + assert.Contains(t, strings.TrimSpace(string(stdout)), "OK") +} + +// TestApiWithInputFile verifies that --input reads a request body from a file. +func TestApiWithInputFile(t *testing.T) { + initApiTest(t) + // Write an AQL query to a temp file and POST it to the AQL search endpoint. + payloadFile := tests.CreateTempFile(t, "items.find({})") + _, stderr, err := tests.GetCmdOutput(t, apiCli, "api", "--method=POST", "--input="+payloadFile, "artifactory/api/search/aql") + require.NoError(t, err) + assert.Equal(t, "200", strings.TrimSpace(string(stderr))) +} diff --git a/buildtools/cli.go b/buildtools/cli.go index 8fa7284eb..60058bc2d 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -32,8 +32,8 @@ import ( huggingfaceCommands "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/huggingface" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/mvn" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/npm" - "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/pnpm" containerutils "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ocicontainer" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/pnpm" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/terraform" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/yarn" commandsUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" @@ -68,13 +68,13 @@ import ( "github.com/jfrog/jfrog-cli/docs/buildtools/mvnconfig" "github.com/jfrog/jfrog-cli/docs/buildtools/npmcommand" "github.com/jfrog/jfrog-cli/docs/buildtools/npmconfig" - "github.com/jfrog/jfrog-cli/docs/buildtools/pnpmcommand" nugetdocs "github.com/jfrog/jfrog-cli/docs/buildtools/nuget" "github.com/jfrog/jfrog-cli/docs/buildtools/nugetconfig" "github.com/jfrog/jfrog-cli/docs/buildtools/pipconfig" "github.com/jfrog/jfrog-cli/docs/buildtools/pipenvconfig" "github.com/jfrog/jfrog-cli/docs/buildtools/pipenvinstall" "github.com/jfrog/jfrog-cli/docs/buildtools/pipinstall" + "github.com/jfrog/jfrog-cli/docs/buildtools/pnpmcommand" "github.com/jfrog/jfrog-cli/docs/buildtools/pnpmconfig" "github.com/jfrog/jfrog-cli/docs/buildtools/poetry" "github.com/jfrog/jfrog-cli/docs/buildtools/poetryconfig" diff --git a/docker_test.go b/docker_test.go index e777dd121..722b03c09 100644 --- a/docker_test.go +++ b/docker_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" - urfavecli "github.com/urfave/cli" "github.com/jfrog/jfrog-client-go/utils/log" + urfavecli "github.com/urfave/cli" tests2 "github.com/jfrog/jfrog-cli-artifactory/utils/tests" diff --git a/docs/general/api/help.go b/docs/general/api/help.go new file mode 100644 index 000000000..e45d1b807 --- /dev/null +++ b/docs/general/api/help.go @@ -0,0 +1,62 @@ +package api + +import "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + +var Usage = []string{"api "} + +func GetDescription() string { + return "Invoke a JFrog Platform HTTP API using the configured server URL and credentials (hostname and token are not passed manually; use 'jf config' or --url / --access-token / --server-id as usual). REST API reference: " + coreutils.JFrogHelpUrl + "jfrog-platform-documentation/rest-apis (OpenAPI bundles are not shipped with the CLI)." +} + +func GetArguments() string { + return ` endpoint-path + API path on the platform host (for example: /access/api/v1/users or /artifactory/api/repositories). The configured platform base URL is prepended automatically. + +EXAMPLES + # List users (Access API) + $ jf api /access/api/v2/users + + # Get a user by name + $ jf api /access/api/v2/users/admin + + # Create a user (POST JSON body from a file, or use -d/--data for an inline body — not both) + $ jf api /access/api/v2/users -X POST --input ./user.json -H "Content-Type: application/json" + $ jf api /access/api/v2/users -X POST -d '{"name":"admin"}' -H "Content-Type: application/json" + + # Replace a user + $ jf api /access/api/v2/users/newuser -X PUT --input ./user.json -H "Content-Type: application/json" + + # Delete a user + $ jf api -X DELETE /access/api/v2/users/tempuser + + # List local repositories (Artifactory REST) + $ jf api /artifactory/api/repositories + + # Create a local Maven repository + $ jf api /artifactory/api/repositories/my-maven-local -X PUT -H "Content-Type: application/json" --input ./repo-maven-local.json + + # Update repository configuration + $ jf api /artifactory/api/repositories/libs-release -X POST -H "Content-Type: application/json" --input ./repo-config.json + + # Delete a repository + $ jf api /artifactory/api/repositories/old-repo -X DELETE + + # One Model GraphQL + $ jf api /onemodel/api/v1/graphql -X POST -H "Content-Type: application/json" --input ./graphql-query.json + + # Set a request timeout (seconds) + $ jf api /artifactory/api/repositories --timeout 10 + +OUTPUT + The response body is written to standard output. The HTTP status code is written to standard error as a single line. Non-2xx responses still print the body and exit with status 1. + +REFERENCES + Binary Management (Artifactory): https://docs.jfrog.com/artifactory/reference/ + JFrog Security : https://docs.jfrog.com/security/reference/ + Governance (AppTrust) : https://docs.jfrog.com/governance/reference/ + Integrations : https://docs.jfrog.com/integrations/reference/ + Project Management : https://docs.jfrog.com/projects/reference/ + Platform Administration : https://docs.jfrog.com/administration/reference/ +SEE ALSO + Use 'jf config' to add or select a server. Use --server-id to target a specific configuration.` +} diff --git a/general/api/cli.go b/general/api/cli.go new file mode 100644 index 000000000..c44458e9a --- /dev/null +++ b/general/api/cli.go @@ -0,0 +1,276 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-client-go/http/httpclient" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/httputils" + "github.com/urfave/cli" +) + +type commandContext interface { + Args() cli.Args + String(name string) string + Bool(name string) bool + Int(name string) int + IsSet(name string) bool + StringSlice(name string) []string +} + +const ( + flagVerbose = "verbose" + flagMethod = "method" + flagInput = "input" + flagData = "data" + flagHeader = "header" + flagTimeout = "timeout" +) + +// Command runs an authenticated HTTP request against the configured JFrog Platform base URL. +func Command(c *cli.Context) error { + if c.NArg() != 1 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + + serverDetails, err := cliutils.CreateServerDetailsWithConfigOffer(c, true, commonCliUtils.Platform) + if err != nil { + return err + } + + return runApiCmd(c, serverDetails, os.Stdout, os.Stderr) +} + +func runApiCmd(c commandContext, serverDetails *coreconfig.ServerDetails, stdOut, stdErr io.Writer) error { + if serverDetails.GetUrl() == "" { + return errorutils.CheckErrorf("no JFrog Platform URL specified, either via the --url flag or as part of the server configuration") + } + + pathArg := c.Args().First() + fullURL, err := joinPlatformAPIURL(serverDetails.GetUrl(), pathArg) + if err != nil { + return err + } + + method := httpMethodOrDefault(c) + body, err := resolveRequestBody(c) + if err != nil { + return err + } + + details, err := buildRequestDetails(serverDetails, c) + if err != nil { + return err + } + + timeout := time.Duration(c.Int(flagTimeout)) * time.Second + client, err := newPlatformHttpClient(serverDetails, timeout) + if err != nil { + return err + } + + return exchangeAndPrint(client, c, method, fullURL, body, details, stdOut, stdErr) +} + +func httpMethodOrDefault(c commandContext) string { + method := strings.ToUpper(strings.TrimSpace(c.String(flagMethod))) + if method == "" { + return http.MethodGet + } + return method +} + +func buildRequestDetails(serverDetails *coreconfig.ServerDetails, c commandContext) (*httputils.HttpClientDetails, error) { + authDetails, err := serverDetails.CreateAccessAuthConfig() + if err != nil { + return nil, err + } + httpDetails := authDetails.CreateHttpClientDetails() + details := &httpDetails + if err = applyUserHeaders(c, details); err != nil { + return nil, err + } + return details, nil +} + +func newPlatformHttpClient(serverDetails *coreconfig.ServerDetails, timeout time.Duration) (*httpclient.HttpClient, error) { + builder := httpclient.ClientBuilder(). + SetInsecureTls(serverDetails.InsecureTls). + SetClientCertPath(serverDetails.ClientCertPath). + SetClientCertKeyPath(serverDetails.ClientCertKeyPath) + if timeout > 0 { + builder = builder.SetOverallRequestTimeout(timeout) + } + return builder.Build() +} + +func exchangeAndPrint(client *httpclient.HttpClient, c commandContext, method, fullURL string, body []byte, details *httputils.HttpClientDetails, stdOut, stdErr io.Writer) error { + if c.Bool(flagVerbose) { + writeVerboseRequest(stdErr, method, fullURL, details) + } + + resp, respBody, _, err := client.Send(method, fullURL, body, true, true, *details, "") + if err != nil { + return err + } + if resp == nil { + return errorutils.CheckErrorf("empty response from server") + } + defer func() { + _ = resp.Body.Close() + }() + + if c.Bool(flagVerbose) { + writeVerboseResponse(stdErr, resp, respBody) + } + + if _, err = fmt.Fprintf(stdErr, "%d\n", resp.StatusCode); err != nil { + return errorutils.CheckError(err) + } + + if _, err = stdOut.Write(respBody); err != nil { + return errorutils.CheckError(err) + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return cli.NewExitError(fmt.Sprintf("HTTP %d", resp.StatusCode), 1) + } + return nil +} + +func joinPlatformAPIURL(platformBase, path string) (string, error) { + base := strings.TrimSuffix(strings.TrimSpace(platformBase), "/") + p := strings.TrimSpace(path) + if p == "" { + return "", errorutils.CheckErrorf("API path must not be empty") + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return base + p, nil +} + +func resolveRequestBody(c commandContext) ([]byte, error) { + inputSet := c.IsSet(flagInput) + dataSet := c.IsSet(flagData) + if inputSet && dataSet { + return nil, errorutils.CheckErrorf("only one of --input and --data can be used") + } + if inputSet { + return readInputPayload(c.String(flagInput)) + } + if dataSet { + return []byte(c.String(flagData)), nil + } + return nil, nil +} + +func readInputPayload(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(path) +} + +func applyUserHeaders(c commandContext, details *httputils.HttpClientDetails) error { + for _, raw := range c.StringSlice(flagHeader) { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + key, val, err := parseHeaderKV(raw) + if err != nil { + return err + } + details.AddHeader(key, val) + } + return nil +} + +func parseHeaderKV(s string) (key, val string, err error) { + idx := strings.Index(s, ":") + if idx <= 0 { + return "", "", errorutils.CheckErrorf("header %q must use key:value format", s) + } + key = strings.TrimSpace(s[:idx]) + val = strings.TrimSpace(s[idx+1:]) + if key == "" { + return "", "", errorutils.CheckErrorf("header %q must use key:value format", s) + } + return key, val, nil +} + +func writeVerboseRequest(w io.Writer, method, url string, details *httputils.HttpClientDetails) { + _, _ = fmt.Fprintf(w, "* Request to %s\n", url) + _, _ = fmt.Fprintf(w, "> %s\n", method) + redacted := redactHeaders(details.Headers) + for k, v := range redacted { + _, _ = fmt.Fprintf(w, "> %s: %s\n", k, v) + } + if !hasHeaderFold(details.Headers, "Authorization") { + switch { + case details.AccessToken != "": + _, _ = fmt.Fprintf(w, "> Authorization: Bearer ***\n") + case details.User != "" && details.Password != "": + _, _ = fmt.Fprintf(w, "> Authorization: Basic ***\n") + } + } +} + +func writeVerboseResponse(w io.Writer, resp *http.Response, body []byte) { + _, _ = fmt.Fprintf(w, "* Response %s\n", resp.Status) + for k, vals := range resp.Header { + for _, v := range vals { + _, _ = fmt.Fprintf(w, "< %s: %s\n", k, v) + } + } + if len(body) > 0 { + _, _ = w.Write(body) + if !bytes.HasSuffix(body, []byte("\n")) { + _, _ = fmt.Fprintln(w) + } + } +} + +func hasHeaderFold(h map[string]string, name string) bool { + for k := range h { + if strings.EqualFold(k, name) { + return true + } + } + return false +} + +func redactHeaders(h map[string]string) map[string]string { + if len(h) == 0 { + return nil + } + out := make(map[string]string, len(h)) + for k, v := range h { + if strings.EqualFold(k, "Authorization") { + out[k] = redactedAuthValue(v) + continue + } + out[k] = v + } + return out +} + +func redactedAuthValue(v string) string { + if v == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(v), "bearer ") { + return "Bearer ***" + } + return "***" +} diff --git a/general/api/cli_test.go b/general/api/cli_test.go new file mode 100644 index 000000000..f1d09cf45 --- /dev/null +++ b/general/api/cli_test.go @@ -0,0 +1,637 @@ +package api + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + testhelpers "github.com/jfrog/jfrog-cli/utils/tests" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +func TestJoinPlatformAPIURL(t *testing.T) { + tests := []struct { + name string + base string + path string + want string + wantErr error + }{ + { + name: "base with trailing slash and path with leading slash", + base: "https://acme.jfrog.io/", + path: "/access/api/v1/users", + want: "https://acme.jfrog.io/access/api/v1/users", + }, + { + name: "base without trailing slash and path without leading slash", + base: "https://acme.jfrog.io", + path: "access/api/v1/users", + want: "https://acme.jfrog.io/access/api/v1/users", + }, + { + name: "empty path returns error", + base: "https://x.io", + path: "", + wantErr: errorutils.CheckErrorf("API path must not be empty"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := joinPlatformAPIURL(tt.base, tt.path) + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantErr.Error(), err.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, u) + } + }) + } +} + +func TestParseHeaderKV(t *testing.T) { + tests := []struct { + name string + in string + wantK string + wantV string + wantErr error + }{ + { + name: "valid header", + in: "Content-Type: application/json", + wantK: "Content-Type", + wantV: "application/json", + }, + { + name: "missing colon", + in: "bad", + wantErr: errorutils.CheckErrorf(`header "bad" must use key:value format`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k, v, err := parseHeaderKV(tt.in) + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantErr.Error(), err.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantK, k) + assert.Equal(t, tt.wantV, v) + }) + } +} + +func TestHasHeaderFold(t *testing.T) { + tests := []struct { + name string + hdr map[string]string + key string + want bool + }{ + { + name: "match case insensitive", + hdr: map[string]string{"Authorization": "x"}, + key: "authorization", + want: true, + }, + { + name: "no match", + hdr: map[string]string{"X-Other": "y"}, + key: "authorization", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, hasHeaderFold(tt.hdr, tt.key)) + }) + } +} + +func TestResolveRequestBody(t *testing.T) { + tests := []struct { + name string + args []string + input string + want []byte + wantErr error + }{ + { + name: "data flag", + args: []string{"cmd", "-d", `{"x":1}`}, + want: []byte(`{"x":1}`), + }, + { + name: "input flag", + args: []string{"cmd"}, + input: "input content", + want: []byte("input content"), + }, + { + name: "input and data flags mutually exclusive", + args: []string{"cmd", "--input", "/dev/null", "-d", `{"x":1}`}, + wantErr: errorutils.CheckErrorf("only one of --input and --data can be used"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := cli.NewApp() + app.Flags = []cli.Flag{ + cli.StringFlag{Name: "input"}, + cli.StringFlag{Name: "data, d"}, + } + var b []byte + var err error + app.Action = func(c *cli.Context) error { + b, err = resolveRequestBody(c) + return nil + } + if tt.input != "" { + tempFile := testhelpers.CreateTempFile(t, tt.input) + tt.args = append(tt.args, "--input", tempFile) + } + + require.NoError(t, app.Run(tt.args)) + + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantErr.Error(), err.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, b) + } + }) + } +} + +func TestApi(t *testing.T) { + type mockConfig struct { + handler func(http.ResponseWriter, *http.Request) + path string + response []byte + status int + } + tests := []struct { + name string + args commandArgs + server mockConfig + wantResponse []byte + wantStatus int + wantErr error + }{ + { + name: "success", + args: commandArgs{ + path: "/success", + }, + server: mockConfig{ + path: "/success", + status: 200, + response: []byte("OK"), + }, + wantStatus: 200, + wantResponse: []byte("OK"), + }, + { + name: "default method is GET", + args: commandArgs{ + path: "/default-method", + }, + server: mockConfig{ + path: "/default-method", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(r.Method)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("GET"), + }, + { + name: "POST method", + args: commandArgs{ + path: "/post-endpoint", + method: "POST", + }, + server: mockConfig{ + path: "/post-endpoint", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(r.Method)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("POST"), + }, + { + name: "PUT method", + args: commandArgs{ + path: "/put-endpoint", + method: "PUT", + }, + server: mockConfig{ + path: "/put-endpoint", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(r.Method)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("PUT"), + }, + { + name: "DELETE method", + args: commandArgs{ + path: "/delete-endpoint", + method: "DELETE", + }, + server: mockConfig{ + path: "/delete-endpoint", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(r.Method)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("DELETE"), + }, + { + name: "PATCH method", + args: commandArgs{ + path: "/patch-endpoint", + method: "PATCH", + }, + server: mockConfig{ + path: "/patch-endpoint", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(r.Method)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("PATCH"), + }, + { + name: "method case insensitive", + args: commandArgs{ + path: "/case-method", + method: "post", + }, + server: mockConfig{ + path: "/case-method", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(r.Method)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("POST"), + }, + { + name: "all headers are received", + args: commandArgs{ + path: "/all-headers", + headers: map[string]string{ + "X-Custom-1": "value1", + "X-Custom-2": "value2", + }, + }, + server: mockConfig{ + path: "/all-headers", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + header1 := r.Header.Get("X-Custom-1") + header2 := r.Header.Get("X-Custom-2") + if _, err := w.Write([]byte(header1 + "-" + header2)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("value1-value2"), + }, + { + name: "response 201 created", + args: commandArgs{ + path: "/created", + }, + server: mockConfig{ + path: "/created", + status: 201, + response: []byte(`{"id":123}`), + }, + wantStatus: 201, + wantResponse: []byte(`{"id":123}`), + }, + { + name: "response 204 no content", + args: commandArgs{ + path: "/no-content", + }, + server: mockConfig{ + path: "/no-content", + status: 204, + response: []byte{}, + }, + wantStatus: 204, + wantResponse: []byte{}, + }, + { + name: "JSON response content type", + args: commandArgs{ + path: "/json-response", + }, + server: mockConfig{ + path: "/json-response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"key":"value"}`)); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte(`{"key":"value"}`), + }, + { + name: "text response content type", + args: commandArgs{ + path: "/text-response", + }, + server: mockConfig{ + path: "/text-response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("plain text response")); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("plain text response"), + }, + { + name: "verbose flag enabled", + args: commandArgs{ + path: "/verbose-test", + verbose: true, + }, + server: mockConfig{ + path: "/verbose-test", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("response body")); err != nil { // #nosec + t.Log(err) + } + }, + }, + wantStatus: 200, + wantResponse: []byte("response body"), + }, + { + name: "empty response body", + args: commandArgs{ + path: "/empty", + }, + server: mockConfig{ + path: "/empty", + status: 200, + response: []byte{}, + }, + wantStatus: 200, + wantResponse: []byte{}, + }, + { + name: "timeout respected for fast server", + args: commandArgs{ + path: "/timeout-ok", + timeout: 5, + }, + server: mockConfig{ + path: "/timeout-ok", + status: 200, + response: []byte("OK"), + }, + wantStatus: 200, + wantResponse: []byte("OK"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlerFunc := tt.server.handler + if handlerFunc == nil { + handlerFunc = func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tt.server.path, r.URL.Path) + w.WriteHeader(tt.server.status) + if _, err := w.Write(tt.server.response); err != nil { // #nosec + t.Log(err) + } + } + } + srv := httptest.NewServer(http.HandlerFunc(handlerFunc)) + defer srv.Close() + + serverDetails := &coreConfig.ServerDetails{ + Url: srv.URL, + AccessToken: "my-token", + } + + ctx := newMockContext(&tt.args) + + var stdErr bytes.Buffer + var stdOut bytes.Buffer + + err := runApiCmd(ctx, serverDetails, &stdOut, &stdErr) + + if tt.wantErr != nil { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + if tt.wantStatus > 0 { + // Extract status code from stderr, which may contain verbose output + stderrContent := strings.TrimSpace(stdErr.String()) + // The last line contains the status code + lines := strings.Split(stderrContent, "\n") + statusCode := lines[len(lines)-1] + assert.Equal(t, strconv.Itoa(tt.wantStatus), statusCode) + } + + if tt.wantResponse != nil { + assert.Equal(t, strings.TrimSpace(string(tt.wantResponse)), strings.TrimSpace(stdOut.String())) + } + }) + } +} + +func TestApiTimeoutExpired(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate a server that takes longer than the client timeout. + // The client will disconnect before the handler writes anything. + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + })) + defer srv.Close() + + serverDetails := &coreConfig.ServerDetails{ + Url: srv.URL, + AccessToken: "my-token", + } + + ctx := newMockContext(&commandArgs{ + path: "/slow", + timeout: 1, // 1-second timeout; server sleeps for 5 s + }) + + var stdErr, stdOut bytes.Buffer + err := runApiCmd(ctx, serverDetails, &stdOut, &stdErr) + assert.Error(t, err, "expected a timeout error") +} + +type commandArgs struct { + path string + method string + headers map[string]string + verbose bool + timeout int +} + +type mockContext struct { + args []string + stringMap map[string]string + boolMap map[string]bool + intMap map[string]int + setMap map[string]bool + sliceMap map[string][]string +} + +// NewMockContext creates a new mockContext with the given arguments +func newMockContext(cmdArgs *commandArgs) *mockContext { + mc := &mockContext{ + stringMap: make(map[string]string), + boolMap: make(map[string]bool), + intMap: make(map[string]int), + setMap: make(map[string]bool), + sliceMap: make(map[string][]string), + } + if cmdArgs.path != "" { + mc.args = append(mc.args, cmdArgs.path) + } + if cmdArgs.method != "" { + mc.setString(flagMethod, cmdArgs.method) + } + if cmdArgs.headers != nil { + var headers []string + for k, v := range cmdArgs.headers { + headers = append(headers, fmt.Sprintf("%v: %v", k, v)) + } + mc.setStringSlice(flagHeader, headers) + } + if cmdArgs.verbose { + mc.setBool(flagVerbose, true) + } + if cmdArgs.timeout > 0 { + mc.setInt(flagTimeout, cmdArgs.timeout) + } + return mc +} + +// Args returns the positional arguments +func (mc *mockContext) Args() cli.Args { + return mc.args +} + +// String returns the string value of a named flag +func (mc *mockContext) String(name string) string { + if val, ok := mc.stringMap[name]; ok { + return val + } + return "" +} + +// Bool returns the boolean value of a named flag +func (mc *mockContext) Bool(name string) bool { + if val, ok := mc.boolMap[name]; ok { + return val + } + return false +} + +// Int returns the integer value of a named flag +func (mc *mockContext) Int(name string) int { + if val, ok := mc.intMap[name]; ok { + return val + } + return 0 +} + +// IsSet returns whether a named flag was set +func (mc *mockContext) IsSet(name string) bool { + if set, ok := mc.setMap[name]; ok { + return set + } + return false +} + +// StringSlice returns a string slice value of a named flag +func (mc *mockContext) StringSlice(name string) []string { + if val, ok := mc.sliceMap[name]; ok { + return val + } + return []string{} +} + +// Helper methods for setting flag values in tests +func (mc *mockContext) setString(name, value string) { + mc.stringMap[name] = value + mc.setMap[name] = true +} + +func (mc *mockContext) setBool(name string, value bool) { + mc.boolMap[name] = value + if value { + mc.setMap[name] = true + } +} + +func (mc *mockContext) setStringSlice(name string, values []string) { + mc.sliceMap[name] = values + mc.setMap[name] = true +} + +func (mc *mockContext) setInt(name string, value int) { + mc.intMap[name] = value + mc.setMap[name] = true +} diff --git a/general/summary/cli.go b/general/summary/cli.go index d9137edd9..deac5f2c1 100644 --- a/general/summary/cli.go +++ b/general/summary/cli.go @@ -131,7 +131,7 @@ func saveFile(content, filePath string) (err error) { if content == "" { return nil } -// #nosec G703 -- filePath is constructed from SummaryOutputDirPathEnv set by CLI, not arbitrary user input and filePath is already cleaned. + // #nosec G703 -- filePath is constructed from SummaryOutputDirPathEnv set by CLI, not arbitrary user input and filePath is already cleaned. file, err := os.Create(filepath.Clean(filePath)) if err != nil { return err @@ -151,7 +151,7 @@ func getSectionMarkdownContent(section MarkdownSection) (string, error) { if _, err := os.Stat(sectionFilepath); os.IsNotExist(err) { return "", nil } -// #nosec G703 -- sectionFilepath is constructed from SummaryOutputDirPathEnv set by CLI, not arbitrary user input + // #nosec G703 -- sectionFilepath is constructed from SummaryOutputDirPathEnv set by CLI, not arbitrary user input contentBytes, err := os.ReadFile(sectionFilepath) if err != nil { return "", fmt.Errorf("error reading markdown file for section %s: %w", section, err) diff --git a/main.go b/main.go index 845ad694d..aa85401af 100644 --- a/main.go +++ b/main.go @@ -30,10 +30,12 @@ import ( "github.com/jfrog/jfrog-cli/config" "github.com/jfrog/jfrog-cli/docs/common" aiDocs "github.com/jfrog/jfrog-cli/docs/general/ai" + apiDocs "github.com/jfrog/jfrog-cli/docs/general/api" loginDocs "github.com/jfrog/jfrog-cli/docs/general/login" oidcDocs "github.com/jfrog/jfrog-cli/docs/general/oidc" summaryDocs "github.com/jfrog/jfrog-cli/docs/general/summary" tokenDocs "github.com/jfrog/jfrog-cli/docs/general/token" + "github.com/jfrog/jfrog-cli/general/api" "github.com/jfrog/jfrog-cli/general/login" "github.com/jfrog/jfrog-cli/general/summary" "github.com/jfrog/jfrog-cli/general/token" @@ -307,6 +309,17 @@ func getCommands() ([]cli.Command, error) { Category: otherCategory, Action: token.AccessTokenCreateCmd, }, + { + Name: "api", + Flags: cliutils.GetCommandFlags(cliutils.Api), + Usage: apiDocs.GetDescription(), + HelpName: corecommon.CreateUsage("api", apiDocs.GetDescription(), apiDocs.Usage), + UsageText: apiDocs.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + BashComplete: corecommon.CreateBashCompletionFunc(), + Category: otherCategory, + Action: api.Command, + }, { Name: "exchange-oidc-token", Aliases: []string{"eot"}, diff --git a/main_test.go b/main_test.go index b7b10e151..0c863f03f 100644 --- a/main_test.go +++ b/main_test.go @@ -91,6 +91,9 @@ func setupIntegrationTests() { if *tests.TestEvidence { InitEvidenceTests() } + if *tests.TestApi { + InitApiTests() + } if *tests.TestHelm { InitHelmTests() } diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index bd0e845fa..b31b143d4 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -127,6 +127,7 @@ const ( // Access Token Create commands keys AccessTokenCreate = "access-token-create" ExchangeOidcToken = "exchange-oidc-token" + Api = "api" // *** Artifactory Commands' flags *** // Base flags @@ -604,6 +605,14 @@ const ( HfHubEtagTimeout = "hf-hub-etag-timeout" HfHubDownloadTimeout = "hf-hub-download-timeout" RepoKey = "repo-key" + + // API command flags + apiHeader = "api-header" + apiInput = "api-input" + apiData = "api-data" + apiMethod = "api-method" + apiVerbose = "api-verbose" + apiTimeout = "api-timeout" ) var flagsMap = map[string]cli.Flag{ @@ -640,6 +649,31 @@ var flagsMap = map[string]cli.Flag{ Name: accessTokenStdin, Usage: "[Default: false] Set to true to provide the access token via stdin.` `", }, + apiHeader: cli.StringSliceFlag{ + Name: "header, H", + Usage: "[Optional] Add an HTTP request header in key:value format. May be repeated.` `", + Value: &cli.StringSlice{}, + }, + apiInput: cli.StringFlag{ + Name: "input", + Usage: "[Optional] File to use as the HTTP request body (use \"-\" to read from standard input). Mutually exclusive with --data; use one or the other to supply the HTTP body.` `", + }, + apiData: cli.StringFlag{ + Name: "data, d", + Usage: "[Optional] Request body as a literal string. Mutually exclusive with --input; use one or the other to supply the HTTP body.` `", + }, + apiMethod: cli.StringFlag{ + Name: "method, X", + Usage: "[Default: GET] HTTP method for the request.` `", + }, + apiVerbose: cli.BoolFlag{ + Name: "verbose", + Usage: "[Default: false] Print the full HTTP request (credentials redacted) and response to standard error.` `", + }, + apiTimeout: cli.IntFlag{ + Name: "timeout", + Usage: "[Default: 0] Overall HTTP request timeout in seconds. 0 means no timeout.` `", + }, // Artifactory's commands flags url: cli.StringFlag{ Name: url, @@ -2033,6 +2067,11 @@ var commandFlags = map[string][]string{ Stats: { xrOutput, accessToken, serverId, }, + Api: { + platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, + ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, + apiHeader, apiInput, apiData, apiMethod, apiVerbose, apiTimeout, + }, TemplateConsumer: { url, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, ClientCertKeyPath, vars, diff --git a/utils/cliutils/utils_test.go b/utils/cliutils/utils_test.go index f1a17a560..335bc27e0 100644 --- a/utils/cliutils/utils_test.go +++ b/utils/cliutils/utils_test.go @@ -368,7 +368,7 @@ type redirectingTransport struct { func (t *redirectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.String() == t.targetURL { // Create a new request to the redirect URL - // #nosec G704 -- redirectURL is a controlled test value, not user input + // #nosec G704 -- redirectURL is a controlled test value, not user input redirectReq, err := http.NewRequest(req.Method, t.redirectURL, req.Body) //nolint:gosec // G704 - URL is a test-controlled constant if err != nil { return nil, err diff --git a/utils/tests/io.go b/utils/tests/io.go new file mode 100644 index 000000000..710e0eb9a --- /dev/null +++ b/utils/tests/io.go @@ -0,0 +1,18 @@ +package tests + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func CreateTempFile(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "api-input-*.json") + require.NoError(t, err) + _, err = f.WriteString(content) + require.NoError(t, err) + require.NoError(t, f.Close()) + return f.Name() +} diff --git a/utils/tests/utils.go b/utils/tests/utils.go index 74d55744a..c0b1ba233 100644 --- a/utils/tests/utils.go +++ b/utils/tests/utils.go @@ -78,6 +78,7 @@ var ( TestTransfer *bool TestLifecycle *bool TestEvidence *bool + TestApi *bool HideUnitTestLog *bool ciRunId *string InstallDataTransferPlugin *bool @@ -119,6 +120,7 @@ func init() { TestTransfer = flag.Bool("test.transfer", false, "Test files transfer") TestLifecycle = flag.Bool("test.lifecycle", false, "Test lifecycle") TestEvidence = flag.Bool("test.evidence", false, "Test evidence") + TestApi = flag.Bool("test.api", false, "Test api command") ContainerRegistry = flag.String("test.containerRegistry", "localhost:8082", "Container registry") HideUnitTestLog = flag.Bool("test.hideUnitTestLog", false, "Hide unit tests logs and print it in a file") InstallDataTransferPlugin = flag.Bool("test.installDataTransferPlugin", false, "Install data-transfer plugin on the source Artifactory server")