diff --git a/CHANGELOG.md b/CHANGELOG.md index 79812db..c786726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ Certinfo: multi-sink output — typed Result as source of truth, console via internal/view Doc + lipgloss renderer, and `--format json` (schemaVersion, no ANSI). MCP certinfo returns JSON. + Jwtinfo: multi-sink output — typed Result as source of truth, console via internal/view Doc, and `--format json` (schemaVersion, no ANSI). MCP jwtinfo returns JSON. + + Requests: multi-sink output — typed Result as source of truth, console via internal/view Doc, and `--format json` (schemaVersion, no ANSI). MCP requests returns JSON. + + Requests: add `followRedirects` configuration option (defaulting to false) in JSON schema and Go client to control HTTP 3xx redirection. + + Requests: adopt traffic-light color progression for HTTP status codes (2xx green, 3xx yellow, 4xx peach, 5xx red). + Requests: pin Go 1.27 ML-KEM hybrid CurvePreferences (including P-521 fallback) and print the negotiated key exchange. Jwtinfo: add sentinel and typed errors for errors.Is/As, and route CLI/MCP display through errdisp domain leaves. @@ -28,6 +36,14 @@ ### Fix + Cmd: write JSON output to cmd.OutOrStdout() directly via fmt.Fprintln instead of cmd.Println to ensure payloads go to stdout. + + Requests: gate response body output on `printResponseBody` so configuring `responseBodyMatchRegexp` does not inadvertently dump the response body. + + Requests: reject duplicate request names with DuplicateRequestNameError in ExecuteWithWriter and BuildResult to preserve distinct response data. + + Certinfo: safely handle nil SerialNumber in FromX509. + Certinfo: read the CA bundle once in SetCaPoolFromFile and derive both CertPool and certificate slice from the same PEM bytes. Certinfo: omit protocol/cipher scan sections until ProbeTLSInfo has completed; keep negotiated TLS output when requested. @@ -40,8 +56,16 @@ Certinfo: collapse styled and plain cert printers onto CertsDoc + view.Render; separate collect from present (cache CA certs, probe TLS before sinks). + Jwtinfo: separate collect from present onto BuildDoc + view.Render; route `--format json` token persistence and refresh progress to stderr to keep stdout machine-readable. + + Requests: separate HTTP/TLS execution from presentation onto BuildDoc + view.Render; route `--format json` output purely to stdout with debug logs routed to stderr. + + View: centralize Chroma syntax highlighting on Code nodes with line boundary preservation. + ### Tests + Devenv: expand requests test suite to cover all schema settings combinations including responseHeadersFilter, responseCertificatesFilter, request/response debug dumps, baseRequest YAML anchors, userAgent and custom headers, multi-host with default URI fallback, regex matching without body printing, quiet mode, and --format json schema validation. + Certinfo: assert PrivateKey match label and value together under plain PrintData rendering. Certinfo and requests: share CA/leaf certificate generation and custom TLS httptest servers via internal/tlstest. diff --git a/assets/examples/https-wrench-http-methods.yaml b/assets/examples/https-wrench-http-methods.yaml index 43c5832..f02b548 100644 --- a/assets/examples/https-wrench-http-methods.yaml +++ b/assets/examples/https-wrench-http-methods.yaml @@ -4,7 +4,7 @@ debug: false verbose: true requests: - - name: httpBunGetDefault + - name: httpBinGetDefault requestHeaders: - key: x-custom-header @@ -14,11 +14,11 @@ requests: # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /get?method=DefaultGet - - name: httpBunGetDeclared + - name: httpBinGetDeclared requestMethod: GET requestHeaders: @@ -29,11 +29,11 @@ requests: # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /get?clientMethod=GET - - name: httpBunPost + - name: httpBinPost requestMethod: POST requestHeaders: @@ -44,11 +44,11 @@ requests: # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /post - - name: httpBunPostBodyText + - name: httpBinPostBodyText requestMethod: POST requestHeaders: @@ -60,11 +60,11 @@ requests: # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /post - - name: httpBunPostBodyJson + - name: httpBinPostBodyJson requestMethod: POST requestHeaders: @@ -76,40 +76,40 @@ requests: # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /post - - name: httpBunPut + - name: httpBinPut requestMethod: PUT # printResponseHeaders: true # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /put - - name: httpBunDelete + - name: httpBinDelete requestMethod: DELETE # printResponseHeaders: true # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /delete - - name: httpBunPatch + - name: httpBinPatch requestMethod: PATCH # printResponseHeaders: true # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /patch @@ -131,33 +131,33 @@ requests: uriList: - /api/v1/requests - - name: httpBunTraceNotAllowed + - name: httpBinTraceNotAllowed requestMethod: TRACE # printResponseHeaders: true # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /get - - name: httpBunTrace + - name: httpBinTrace requestMethod: TRACE # printResponseHeaders: true # printResponseBody: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz uriList: - /any - - name: httpBunHead + - name: httpBinHead requestMethod: HEAD # requestDebug: true # printResponseHeaders: true hosts: - - name: httpbun.com + - name: httpbin.os76.xyz diff --git a/assets/examples/https-wrench-httpbin-os76.yaml b/assets/examples/https-wrench-httpbin-os76.yaml index a35f849..b550306 100644 --- a/assets/examples/https-wrench-httpbin-os76.yaml +++ b/assets/examples/https-wrench-httpbin-os76.yaml @@ -9,11 +9,11 @@ baseRequest: &httpbin requests: - <<: *httpbin name: httpbin-443 - responseBodyMatchRegexp: go-httpbin + responseBodyMatchRegexp: teapot hosts: - name: httpbin.os76.xyz uriList: - - / + - /status/418 - <<: *httpbin name: httpbin-transportOverride diff --git a/assets/examples/tests-configs/base-request-template.yaml b/assets/examples/tests-configs/base-request-template.yaml new file mode 100644 index 0000000..9c66ba2 --- /dev/null +++ b/assets/examples/tests-configs/base-request-template.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=../../../https-wrench.schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj +--- +debug: false +verbose: true + +baseRequest: &base + transportOverrideUrl: https://localhost:9443 + insecure: true + userAgent: wrench-base-template + printResponseBody: true + +requests: + - <<: *base + name: BaseTemplateReq1 + hosts: + - name: localhost + uriList: + - /user-agent + + - <<: *base + name: BaseTemplateReq2 + requestHeaders: + - key: X-Merged-Template + value: Active + hosts: + - name: localhost + uriList: + - /headers diff --git a/assets/examples/tests-configs/body-regexp-match.yaml b/assets/examples/tests-configs/body-regexp-match.yaml index ff3cb66..4afe261 100644 --- a/assets/examples/tests-configs/body-regexp-match.yaml +++ b/assets/examples/tests-configs/body-regexp-match.yaml @@ -8,5 +8,6 @@ requests: transportOverrideUrl: https://localhost:9443 responseBodyMatchRegexp: /dev/null + ! printf '%s\n' "$out" | grep -q $'\x1b' + ''; + + scripts.test-requests-quiet.exec = '' + gum format "## test request quiet mode (verbose: false)" + set -eo pipefail + out=$(./dist/https-wrench requests --config ./${config.env.EXAMPLES}/tests-configs/quiet-mode.yaml --ca-bundle $CAROOT/rootCA.pem) + ! printf '%s\n' "$out" | grep 'StatusCode:' + ''; + + scripts.test-requests-http-status-codes.exec = '' + gum format "## test request HTTP status codes (2xx, 3xx, 4xx, 5xx) and followRedirects" + set -eo pipefail + out=$(./dist/https-wrench requests --config ./${config.env.EXAMPLES}/tests-configs/http-status-codes.yaml --ca-bundle $CAROOT/rootCA.pem) + # 2xx + printf '%s\n' "$out" | grep -E 'StatusCode:\s+200' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+201' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+202' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+204' + # 3xx + printf '%s\n' "$out" | grep -E 'StatusCode:\s+301' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+302' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+304' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+307' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+308' + # 3xx followRedirects + json_out=$(./dist/https-wrench requests --config ./${config.env.EXAMPLES}/tests-configs/http-status-codes.yaml --ca-bundle $CAROOT/rootCA.pem --format json) + printf '%s\n' "$json_out" | jq -e '.requests[] | select(.name == "statusCodes3xxFollow") | .responses[0].statusCode == 200' > /dev/null + # 4xx + printf '%s\n' "$out" | grep -E 'StatusCode:\s+400' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+401' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+403' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+404' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+405' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+418' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+429' + # 5xx + printf '%s\n' "$out" | grep -E 'StatusCode:\s+500' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+502' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+503' + printf '%s\n' "$out" | grep -E 'StatusCode:\s+504' + ''; + scripts.test-certinfo-encrypted-rsa-key.exec = '' gum format "## test certinfo load encrypted RSA key using env var" export CERTINFO_PKEY_PW=$KEY_TEST_PW @@ -671,6 +778,16 @@ in test-requests-proxy-protocol-ipv6 test-requests-ca-bundle-yaml test-requests-body-regexp-match + test-requests-body-regexp-no-print + test-requests-response-headers-filter + test-requests-response-certificates-filter + test-requests-debug-dumps + test-requests-base-request + test-requests-user-agent-and-headers + test-requests-multiple-hosts-default-uri + test-requests-format-json + test-requests-quiet + test-requests-http-status-codes ''; scripts.run-certinfo-tlsendpoint-tests.exec = '' diff --git a/https-wrench.schema.json b/https-wrench.schema.json index 4b4f18d..5373300 100644 --- a/https-wrench.schema.json +++ b/https-wrench.schema.json @@ -126,6 +126,10 @@ "type": "boolean", "description": "If true, skips TLS server certificate verification (InsecureSkipVerify)." }, + "followRedirects": { + "type": "boolean", + "description": "If true, follows HTTP redirects. Defaults to false." + }, "responseHeadersFilter": { "type": "array", "description": "Response header names to include when printResponseHeaders is enabled.", diff --git a/internal/certinfo/result.go b/internal/certinfo/result.go index e4e767e..6695767 100644 --- a/internal/certinfo/result.go +++ b/internal/certinfo/result.go @@ -126,7 +126,7 @@ func (c *Config) BuildResult() (*Result, error) { if len(c.CACertsFilePath) > 0 { r.CACerts = &CertsSection{ FilePath: c.CACertsFilePath, - Certificates: certInfos(c.CACerts), + Certificates: CertInfos(c.CACerts), } } @@ -161,7 +161,7 @@ func certsSectionFromBundle( FilePath: filePath, Endpoint: endpoint, ServerName: serverName, - Certificates: certInfos(certs), + Certificates: CertInfos(certs), } if privKey != nil && len(certs) > 0 { @@ -176,16 +176,18 @@ func certsSectionFromBundle( return sec, nil } -func certInfos(certs []*x509.Certificate) []CertInfo { +// CertInfos converts a slice of x509.Certificate into serializable CertInfo structs. +func CertInfos(certs []*x509.Certificate) []CertInfo { out := make([]CertInfo, 0, len(certs)) for i, cert := range certs { - out = append(out, certInfoFromX509(i, cert)) + out = append(out, FromX509(i, cert)) } return out } -func certInfoFromX509(index int, cert *x509.Certificate) CertInfo { +// FromX509 builds a plain CertInfo from an x509.Certificate. +func FromX509(index int, cert *x509.Certificate) CertInfo { if cert == nil { return CertInfo{Index: index} } @@ -197,6 +199,11 @@ func certInfoFromX509(index int, cert *x509.Certificate) CertInfo { days := time.Until(cert.NotAfter).Hours() / 24 + var serialNumber string + if cert.SerialNumber != nil { + serialNumber = cert.SerialNumber.String() + } + return CertInfo{ Index: index, Subject: cert.Subject.String(), @@ -212,7 +219,7 @@ func certInfoFromX509(index int, cert *x509.Certificate) CertInfo { SubjectKeyID: hex.EncodeToString(cert.SubjectKeyId), PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), SignatureAlgorithm: cert.SignatureAlgorithm.String(), - SerialNumber: cert.SerialNumber.String(), + SerialNumber: serialNumber, FingerprintSHA256: fmt.Sprintf("%x", sha256.Sum256(cert.Raw)), } } diff --git a/internal/certinfo/result_test.go b/internal/certinfo/result_test.go index 3272bff..f76a83a 100644 --- a/internal/certinfo/result_test.go +++ b/internal/certinfo/result_test.go @@ -6,6 +6,7 @@ package certinfo import ( "bytes" + "crypto/x509" "encoding/json" "testing" @@ -53,3 +54,15 @@ func TestBuildDoc_PlainHasNoANSI(t *testing.T) { require.Contains(t, buf.String(), "Certinfo") require.NotContains(t, buf.String(), "\x1b[") } + +func TestFromX509_NilSerialNumber(t *testing.T) { + t.Parallel() + + info := FromX509(0, nil) + require.Equal(t, 0, info.Index) + require.Empty(t, info.SerialNumber) + + info = FromX509(1, &x509.Certificate{}) + require.Equal(t, 1, info.Index) + require.Empty(t, info.SerialNumber) +} diff --git a/internal/certinfo/view.go b/internal/certinfo/view.go index 6917e74..9718c68 100644 --- a/internal/certinfo/view.go +++ b/internal/certinfo/view.go @@ -295,6 +295,11 @@ func fieldRequested(fields []string, names ...string) bool { // CertsDoc builds certificate table nodes from x509 certificates with optional field filters. func CertsDoc(certs []*x509.Certificate, filter ...[]map[int][]string) view.Doc { + return CertInfosDoc(CertInfos(certs), filter...) +} + +// CertInfosDoc builds certificate table nodes from CertInfo slices with optional field filters. +func CertInfosDoc(certs []CertInfo, filter ...[]map[int][]string) view.Doc { var f []map[int][]string if len(filter) > 0 { f = filter[0] @@ -312,19 +317,19 @@ func CertsDoc(certs []*x509.Certificate, filter ...[]map[int][]string) view.Doc } nodes := make([]view.Node, 0, len(certs)) - for i, cert := range certs { + + for _, info := range certs { var fields []string if hasFilter { var ok bool - fields, ok = requested[i] + fields, ok = requested[info.Index] if !ok { continue } } - info := certInfoFromX509(i, cert) nodes = append(nodes, certInfoTable(info, fields)) } diff --git a/internal/cmd/certinfo.go b/internal/cmd/certinfo.go index a77657b..9939331 100644 --- a/internal/cmd/certinfo.go +++ b/internal/cmd/certinfo.go @@ -5,6 +5,7 @@ Copyright © 2025 Zeno Belli xeno@os76.xyz package cmd import ( + "fmt" "os" "github.com/spf13/cobra" @@ -157,7 +158,7 @@ Examples: return } - cmd.Println(string(payload)) + _, _ = fmt.Fprintln(out, string(payload)) return } diff --git a/internal/cmd/embedded/config-example.yaml b/internal/cmd/embedded/config-example.yaml index f3a82ee..73c6a3f 100644 --- a/internal/cmd/embedded/config-example.yaml +++ b/internal/cmd/embedded/config-example.yaml @@ -39,6 +39,9 @@ requests: ## insecure - If true, skips TLS certificate verification (InsecureSkipVerify). insecure: false + ## followRedirects - If true, follows HTTP redirects. Defaults to false. + followRedirects: false + ## requestDebug - If true, dumps the raw HTTP request to the output for debugging. requestDebug: false diff --git a/internal/cmd/jwtinfo.go b/internal/cmd/jwtinfo.go index be8bb8b..0dfa9e3 100644 --- a/internal/cmd/jwtinfo.go +++ b/internal/cmd/jwtinfo.go @@ -219,7 +219,7 @@ Examples: return } - cmd.Println(string(payload)) + _, _ = fmt.Fprintln(out, string(payload)) } else { opts := view.Options{} if f, ok := out.(*os.File); ok && term.IsTerminal(int(f.Fd())) { diff --git a/internal/cmd/requests.go b/internal/cmd/requests.go index b8f901f..324e17f 100644 --- a/internal/cmd/requests.go +++ b/internal/cmd/requests.go @@ -5,7 +5,6 @@ Copyright © 2025 Zeno Belli xeno@os76.xyz package cmd import ( - "context" _ "embed" "fmt" "os" @@ -15,12 +14,15 @@ import ( "github.com/spf13/viper" "github.com/xenos76/https-wrench/internal/errdisp" "github.com/xenos76/https-wrench/internal/requests" + "github.com/xenos76/https-wrench/internal/view" + "golang.org/x/term" ) var ( //go:embed embedded/config-example.yaml sampleYamlConfig string showSampleConfig bool + requestsFmt string ) var requestsCmd = &cobra.Command{ @@ -51,6 +53,13 @@ Examples: return } + switch requestsFmt { + case "", "text", "json": + default: + cmd.Printf("Error: unsupported --format %q (use text or json)\n", requestsFmt) + return + } + if showSampleConfig { fmt.Fprint(cmd.OutOrStdout(), sampleYamlConfig) return @@ -75,7 +84,7 @@ Examples: return } - if cfg.Debug { + if cfg.Debug && requestsFmt != "json" { dump.Print(cfg) } @@ -97,15 +106,50 @@ Examples: cmd.Print(errdisp.Format(err)) } - responseMap, err := requests.HandleRequests(context.Background(), cmd.OutOrStdout(), requestsCfg) + debugOut := cmd.OutOrStdout() + if requestsFmt == "json" { + debugOut = cmd.ErrOrStderr() + } + + result, responseMap, err := requestsCfg.ExecuteWithWriter(cmd.Context(), debugOut) if err != nil { cmd.Print(errdisp.Format(err)) + return } if cfg.Debug { - // dump.Print(cfg) - dump.Print(requestsCfg) - dump.Print(responseMap) + if requestsFmt == "json" { + dump.Fprint(cmd.ErrOrStderr(), requestsCfg) + dump.Fprint(cmd.ErrOrStderr(), responseMap) + } else { + dump.Print(requestsCfg) + dump.Print(responseMap) + } + } + + out := cmd.OutOrStdout() + + if requestsFmt == "json" { + payload, encErr := requests.EncodeJSON(result) + if encErr != nil { + cmd.Printf("error encoding Requests JSON: %s\n", errdisp.FormatCause(encErr)) + return + } + + _, _ = fmt.Fprintln(out, string(payload)) + + return + } + + if cfg.Verbose { + opts := view.Options{} + if f, ok := out.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + opts.ForceColor = true + } + + if err = view.Render(out, requests.BuildDoc(result), opts); err != nil { + cmd.Printf("error printing Requests data: %s\n", errdisp.FormatCause(err)) + } } }, } @@ -115,5 +159,11 @@ func init() { "show-sample-config", false, "Show a sample YAML configuration") + requestsCmd.Flags().StringVar( + &requestsFmt, + "format", + "text", + "Output format: text (human-readable) or json (machine-readable, no ANSI)", + ) rootCmd.AddCommand(requestsCmd) } diff --git a/internal/cmd/requests_test.go b/internal/cmd/requests_test.go index 3a78f4a..32d1831 100644 --- a/internal/cmd/requests_test.go +++ b/internal/cmd/requests_test.go @@ -3,6 +3,10 @@ package cmd import ( "bytes" _ "embed" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" _ "github.com/breml/rootcerts" @@ -29,6 +33,7 @@ func TestRequestsCmd(t *testing.T) { "Global Flags:", "--config", "--ca-bundle", + "--format", "--show-sample-config", "--version", "--help", @@ -106,6 +111,14 @@ func TestRequestsCmd(t *testing.T) { // "--version Display the version", // }, // }, + { + name: "unsupported format", + args: []string{"requests", "--format", "invalid"}, + expectError: false, + expected: []string{ + "Error: unsupported --format \"invalid\" (use text or json)", + }, + }, } for _, tc := range tests { @@ -117,6 +130,7 @@ func TestRequestsCmd(t *testing.T) { require.NoError(t, requestsCmd.Flags().Set("ca-bundle", "")) require.NoError(t, rootCmd.Flags().Set("config", "")) require.NoError(t, requestsCmd.Flags().Set("show-sample-config", "false")) + require.NoError(t, requestsCmd.Flags().Set("format", "text")) }) reqOut := new(bytes.Buffer) @@ -177,3 +191,94 @@ func TestRequestsCmd_ShowSampleConfigStdout(t *testing.T) { gotStderr := stderr.String() require.Empty(t, gotStderr, "Expected stderr to be empty, but got: %s", gotStderr) } + +func setupRequestsCmdConfigFile(t *testing.T, targetURL string) string { + t.Helper() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "requests.yaml") + cfgContent := `verbose: true +requests: + - name: test-cmd-req + requestMethod: GET + insecure: true + printResponseBody: true + printResponseHeaders: true + transportOverrideUrl: "` + targetURL + `" + hosts: + - name: example.com + uriList: + - / +` + require.NoError(t, os.WriteFile(cfgPath, []byte(cfgContent), 0o600)) + + return cfgPath +} + +func TestRequestsCmd_FormatJSONAndText(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer ts.Close() + + cfgPath := setupRequestsCmdConfigFile(t, ts.URL) + + t.Run("format json", func(t *testing.T) { + t.Cleanup(func() { + resetViper() + + cfgFile = "" + + require.NoError(t, requestsCmd.Flags().Set("format", "text")) + + rootCmd.SetArgs(nil) + }) + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + reqCmd := rootCmd + reqCmd.SetOut(stdout) + reqCmd.SetErr(stderr) + reqCmd.SetArgs([]string{"requests", "--config", cfgPath, "--format", "json"}) + + err := reqCmd.Execute() + require.NoError(t, err) + + out := stdout.String() + require.Contains(t, out, `"schemaVersion": "1"`) + require.Contains(t, out, `"command": "requests"`) + require.Contains(t, out, `"name": "test-cmd-req"`) + require.Contains(t, out, `"statusCode": 200`) + require.NotContains(t, out, "\x1b[", "JSON output must not contain ANSI escape sequences") + }) + + t.Run("format text", func(t *testing.T) { + t.Cleanup(func() { + resetViper() + + cfgFile = "" + + require.NoError(t, requestsCmd.Flags().Set("format", "text")) + + rootCmd.SetArgs(nil) + }) + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + reqCmd := rootCmd + reqCmd.SetOut(stdout) + reqCmd.SetErr(stderr) + reqCmd.SetArgs([]string{"requests", "--config", cfgPath, "--format", "text"}) + + err := reqCmd.Execute() + require.NoError(t, err) + + out := stdout.String() + require.Contains(t, out, "Requests") + require.Contains(t, out, "Request: test-cmd-req") + require.Contains(t, out, "StatusCode: 200 OK") + }) +} diff --git a/internal/mcp/tools_exec.go b/internal/mcp/tools_exec.go index 620bb85..a92eee7 100644 --- a/internal/mcp/tools_exec.go +++ b/internal/mcp/tools_exec.go @@ -205,16 +205,21 @@ func runRequestsExec(ctx context.Context, input runRequestsInput) (execToolOutpu return execToolOutput{}, err } - output, err := captureOutput(func(w io.Writer) error { - _, handleErr := requests.HandleRequests(ctx, w, meta) + result, _, err := meta.Execute(ctx) + if err != nil { + return execToolOutput{}, err + } - return handleErr - }) + return requestsJSONOutput(result) +} + +func requestsJSONOutput(result *requests.Result) (execToolOutput, error) { + payload, err := requests.EncodeJSON(result) if err != nil { return execToolOutput{}, err } - return execToolOutput{Output: output}, nil + return execToolOutput{Output: string(payload)}, nil } func executeCertinfo(ctx context.Context, input certinfoInput) (execToolOutput, error) { diff --git a/internal/mcp/tools_exec_test.go b/internal/mcp/tools_exec_test.go index 076e570..8bd48ea 100644 --- a/internal/mcp/tools_exec_test.go +++ b/internal/mcp/tools_exec_test.go @@ -95,6 +95,8 @@ requests: "configPath": cfgPath, }) require.Empty(t, out["error"]) + require.Contains(t, out["output"], `"schemaVersion": "1"`) + require.Contains(t, out["output"], `"command": "requests"`) require.Contains(t, out["output"], "from-file") } diff --git a/internal/requests/errors.go b/internal/requests/errors.go index eb5b4b7..e32df30 100644 --- a/internal/requests/errors.go +++ b/internal/requests/errors.go @@ -26,8 +26,9 @@ var ( ErrTransportOverrideRequired = errors.New( "SetProxyProtocolHeader failed: transportOverrideURL not set", ) - ErrInvalidURI = errors.New("invalid uri") - ErrInvalidTransportURL = errors.New("failed to parse transport override url") + ErrInvalidURI = errors.New("invalid uri") + ErrInvalidTransportURL = errors.New("failed to parse transport override url") + ErrDuplicateRequestName = errors.New("duplicate request name") ) // EmptyArgError is returned when a required string argument is empty. @@ -126,3 +127,19 @@ func (e *InvalidTransportURLError) Error() string { func (*InvalidTransportURLError) Is(target error) bool { return target == ErrInvalidTransportURL } + +// DuplicateRequestNameError is returned when multiple requests share the same name. +// errors.Is(err, ErrDuplicateRequestName) is true. +type DuplicateRequestNameError struct { + Name string +} + +// Error returns a message identifying the duplicate request name. +func (e *DuplicateRequestNameError) Error() string { + return fmt.Sprintf("duplicate request name: %s", e.Name) +} + +// Is reports whether target is ErrDuplicateRequestName. +func (*DuplicateRequestNameError) Is(target error) bool { + return target == ErrDuplicateRequestName +} diff --git a/internal/requests/errors_test.go b/internal/requests/errors_test.go index 741ad19..7d74439 100644 --- a/internal/requests/errors_test.go +++ b/internal/requests/errors_test.go @@ -87,6 +87,13 @@ func TestRequests_errorSentinels_Is(t *testing.T) { } } +func TestRequests_duplicateRequestName_Is(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, &DuplicateRequestNameError{Name: "req-1"}, ErrDuplicateRequestName) + require.ErrorIs(t, fmt.Errorf("validation: %w", &DuplicateRequestNameError{Name: "req-1"}), ErrDuplicateRequestName) +} + // TestRequests_errorTypes_AsType checks errors.AsType for typed requests errors. func TestRequests_errorTypes_AsType(t *testing.T) { t.Parallel() @@ -111,4 +118,10 @@ func TestRequests_errorTypes_AsType(t *testing.T) { require.True(t, ok) require.Equal(t, "/x", gotURI.URI) require.Equal(t, "h", gotURI.Host) + + dup := fmt.Errorf("wrap: %w", &DuplicateRequestNameError{Name: "dup-req"}) + gotDup, ok := errors.AsType[*DuplicateRequestNameError](dup) + require.True(t, ok) + require.Equal(t, "dup-req", gotDup.Name) + require.Equal(t, "duplicate request name: dup-req", gotDup.Error()) } diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 36cefac..dbd9f57 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -19,7 +19,7 @@ import ( "github.com/pires/go-proxyproto" "github.com/xenos76/https-wrench/internal/certinfo" - "github.com/xenos76/https-wrench/internal/style" + "github.com/xenos76/https-wrench/internal/view" ) const ( @@ -112,6 +112,8 @@ type RequestConfig struct { EnableProxyProtocolV2 bool `mapstructure:"enableProxyProtocolV2"` // Insecure skips TLS certificate verification. Insecure bool `mapstructure:"insecure"` + // FollowRedirects indicates if HTTP redirects should be followed. Defaults to false. + FollowRedirects bool `mapstructure:"followRedirects"` // RequestDebug enables dumping the outgoing HTTP request. RequestDebug bool `mapstructure:"requestDebug"` // RequestHeaders is a slice of custom HTTP headers to include in the request. @@ -160,6 +162,8 @@ type ResponseData struct { URL string // ResponseBody is the content of the HTTP response. ResponseBody string + // ResponseContentType indicates the language/type of the response body. + ResponseContentType string // ResponseBodyRegexpMatched indicates if the response body matched the configured regexp. ResponseBodyRegexpMatched bool // Response is the raw HTTP response object. @@ -243,14 +247,59 @@ func (r *RequestsMetaConfig) SetRequests(requests []RequestConfig) *RequestsMeta return r } +// Execute runs all configured HTTP requests and returns the structured Result and response map. +func (r *RequestsMetaConfig) Execute(ctx context.Context) (*Result, map[string][]ResponseData, error) { + return r.ExecuteWithWriter(ctx, io.Discard) +} + +// ExecuteWithWriter runs all configured HTTP requests, writing debug output to w. +func (r *RequestsMetaConfig) ExecuteWithWriter(ctx context.Context, w io.Writer) (*Result, map[string][]ResponseData, error) { + if w == nil { + w = io.Discard + } + + seenNames := make(map[string]struct{}, len(r.Requests)) + for _, reqCfg := range r.Requests { + if _, exists := seenNames[reqCfg.Name]; exists { + return nil, nil, &DuplicateRequestNameError{Name: reqCfg.Name} + } + + seenNames[reqCfg.Name] = struct{}{} + } + + responseDataMap := make(map[string][]ResponseData) + + for _, reqCfg := range r.Requests { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + responseDataList, err := processHTTPRequestsByHost(ctx, w, reqCfg, r.CACertsPool, r.RequestVerbose) + if err != nil { + return nil, nil, err + } + + responseDataMap[reqCfg.Name] = responseDataList + } + + result, err := BuildResult(responseDataMap, r) + if err != nil { + return nil, nil, err + } + + return result, responseDataMap, nil +} + // PrintCmd prints a header for the requests execution if verbose mode is enabled. func (r *RequestsMetaConfig) PrintCmd(w io.Writer) { - if r.RequestVerbose { - fmt.Fprintf( - w, - "\n%s\n", - style.LgSprintf(style.Cmd, "Requests"), - ) + if r.RequestVerbose && w != nil { + _ = view.Render(w, view.Doc{ + Nodes: []view.Node{ + view.Blank{}, + view.Banner{Text: "Requests"}, + view.Blank{}, + }, + }, view.Options{ForceColor: true}) } } @@ -258,14 +307,21 @@ func (r *RequestsMetaConfig) PrintCmd(w io.Writer) { // //nolint:revive func (r *RequestConfig) PrintTitle(w io.Writer, isVerbose bool) { - if isVerbose { - fmt.Fprint(w, style.LgSprintf(style.TitleKey, "Request:")) - fmt.Fprintln(w, style.LgSprintf(style.Title, "%s", r.Name)) - + if isVerbose && w != nil { + kids := make([]view.Node, 0, 1) if r.TransportOverrideURL != "" { - fmt.Fprint(w, style.LgSprintf(style.ItemKey, "Via:")) - fmt.Fprintln(w, style.LgSprintf(style.Via, "%s", r.TransportOverrideURL)) + kids = append(kids, view.KV{Key: "Via", Value: r.TransportOverrideURL, Tone: view.ToneURL}) } + + _ = view.Render(w, view.Doc{ + Nodes: []view.Node{ + view.Section{ + Title: fmt.Sprintf("Request: %s", r.Name), + Level: 1, + Kids: kids, + }, + }, + }, view.Options{ForceColor: true}) } } @@ -354,6 +410,9 @@ func NewRequestHTTPClient() *RequestHTTPClient { ExpectContinueTimeout: transportExpectContinueTimeout, TLSClientConfig: tlsConfig, }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, Timeout: httpClientTimeout, } @@ -385,8 +444,9 @@ func (rc *RequestHTTPClient) SetServerName(serverName string) (*RequestHTTPClien tr.TLSClientConfig.ServerName = serverName rc.client = &http.Client{ - Transport: tr, - Timeout: rc.client.Timeout, + Transport: tr, + CheckRedirect: rc.client.CheckRedirect, + Timeout: rc.client.Timeout, } return rc, nil @@ -416,8 +476,9 @@ func (rc *RequestHTTPClient) SetCACertsPool(caPool *x509.CertPool) (*RequestHTTP tr.TLSClientConfig.RootCAs = caPool rc.client = &http.Client{ - Transport: tr, - Timeout: rc.client.Timeout, + Transport: tr, + CheckRedirect: rc.client.CheckRedirect, + Timeout: rc.client.Timeout, } return rc, nil @@ -438,8 +499,9 @@ func (rc *RequestHTTPClient) SetInsecureSkipVerify(isInsecure bool) (*RequestHTT tr.TLSClientConfig.InsecureSkipVerify = isInsecure rc.client = &http.Client{ - Transport: tr, - Timeout: rc.client.Timeout, + Transport: tr, + CheckRedirect: rc.client.CheckRedirect, + Timeout: rc.client.Timeout, } return rc, nil @@ -501,8 +563,9 @@ func (rc *RequestHTTPClient) SetTransportOverride(transportURL string) (*Request } rc.client = &http.Client{ - Transport: tr, - Timeout: rc.client.Timeout, + Transport: tr, + CheckRedirect: rc.client.CheckRedirect, + Timeout: rc.client.Timeout, } return rc, nil @@ -553,8 +616,9 @@ func (rc *RequestHTTPClient) SetProxyProtocolHeader(header proxyproto.Header) (* } rc.client = &http.Client{ - Transport: tr, - Timeout: rc.client.Timeout, + Transport: tr, + CheckRedirect: rc.client.CheckRedirect, + Timeout: rc.client.Timeout, } return rc, nil @@ -576,6 +640,23 @@ func (rc *RequestHTTPClient) SetClientTimeout(timeout int) (*RequestHTTPClient, return rc, nil } +// SetFollowRedirects configures whether the HTTP client follows redirects. +func (rc *RequestHTTPClient) SetFollowRedirects(follow bool) *RequestHTTPClient { + if rc == nil || rc.client == nil { + return rc + } + + if follow { + rc.client.CheckRedirect = nil + } else { + rc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + + return rc +} + // NewHTTPClientFromRequestConfig initializes a RequestHTTPClient using the provided RequestConfig. func NewHTTPClientFromRequestConfig( r RequestConfig, @@ -583,6 +664,7 @@ func NewHTTPClientFromRequestConfig( caPool *x509.CertPool, ) (*RequestHTTPClient, error) { reqClient := NewRequestHTTPClient() + reqClient.SetFollowRedirects(r.FollowRedirects) _, err := reqClient.SetCACertsPool(caPool) if err != nil { @@ -647,8 +729,6 @@ func processHTTPRequestsByHost( ) ([]ResponseData, error) { var responseDataList []ResponseData - r.PrintTitle(w, isVerbose) - for _, host := range r.Hosts { if err := ctx.Err(); err != nil { return nil, err @@ -695,7 +775,6 @@ func processRequestsForHost( responseData := executeSingleRequest(ctx, w, r, reqClient, reqURL, requestBodyBytes, isVerbose) responseDataList = append(responseDataList, responseData) - responseData.PrintResponseData(w, isVerbose) } return responseDataList, nil diff --git a/internal/requests/requests_handlers.go b/internal/requests/requests_handlers.go index f433bea..3e94f38 100644 --- a/internal/requests/requests_handlers.go +++ b/internal/requests/requests_handlers.go @@ -5,6 +5,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net" @@ -15,10 +16,9 @@ import ( "strconv" "strings" - "github.com/charmbracelet/lipgloss/table" proxyproto "github.com/pires/go-proxyproto" "github.com/xenos76/https-wrench/internal/certinfo" - "github.com/xenos76/https-wrench/internal/style" + "github.com/xenos76/https-wrench/internal/view" ) // String returns the response header as a string. @@ -67,36 +67,28 @@ func cipherSuiteName(id uint16) string { // filterResponseHeaders filters and formats HTTP headers for display based on the provided filter list. func filterResponseHeaders(headers http.Header, filter []string) string { - var outputStr string + var buf bytes.Buffer - var outputMap map[string][]string + filtered := filterHeadersMap(headers, filter) + rows := make([][]view.Cell, 0, len(filtered)) + keys := make([]string, 0, len(filtered)) - sl := style.HeadKeyP3.Render - sv := style.HeadValue.Italic(true).Render - t := style.LGTable - headersFiltered := make(map[string][]string) - - if len(filter) > 0 { - for k, v := range headers { - if present := slices.Contains(filter, k); present { - headersFiltered[k] = v - } - } - - outputMap = headersFiltered - } else { - outputMap = headers + for k := range filtered { + keys = append(keys, k) } - for k, v := range outputMap { - values := strings.Join(v, ", ") - t.Row(sl(k), sv(values)) + slices.Sort(keys) + + for _, k := range keys { + rows = append(rows, []view.Cell{ + {Text: k, Tone: view.ToneKey}, + {Text: strings.Join(filtered[k], ", "), Tone: view.ToneValue}, + }) } - outputStr = t.Render() - t.ClearRows() + _ = view.Render(&buf, view.Doc{Nodes: []view.Node{view.Table{Rows: rows}}}, view.Options{ForceColor: true}) - return outputStr + return buf.String() } // getUrlsFromHost generates a list of full URLs for a host based on its Name and URIList. @@ -216,33 +208,25 @@ func HandleRequests( w io.Writer, cfg *RequestsMetaConfig, ) (map[string][]ResponseData, error) { - responseDataMap := make(map[string][]ResponseData) + if cfg == nil { + return nil, errors.New("requests: nil meta config") + } cfg.PrintCmd(w) - for _, r := range cfg.Requests { - if err := ctx.Err(); err != nil { - return nil, err - } - - responseDataList, err := processHTTPRequestsByHost( - ctx, - w, - r, - cfg.CACertsPool, - cfg.RequestVerbose, - ) - if err != nil { - return nil, err - } + result, respMap, err := cfg.ExecuteWithWriter(ctx, w) + if err != nil { + return nil, err + } - responseDataMap[r.Name] = responseDataList + if cfg.RequestVerbose && w != nil { + _ = view.Render(w, BuildDocWithOptions(result, DocOptions{WithoutBanner: true}), view.Options{ForceColor: true}) } - return responseDataMap, nil + return respMap, nil } -// ImportResponseBody reads the response body, handles regex matching, and applies syntax highlighting if applicable. +// ImportResponseBody reads the response body, handles regex matching, and stores clean body text. func (rd *ResponseData) ImportResponseBody() { if len(rd.ResponseBody) > 0 { return @@ -285,7 +269,9 @@ func (rd *ResponseData) ImportResponseBody() { } if matched := rex.MatchString(contentType); matched { - rd.ResponseBody = style.CodeSyntaxHighlight(item.language, code) + rd.ResponseContentType = item.language + rd.ResponseBody = code + return } } @@ -295,102 +281,76 @@ func (rd *ResponseData) ImportResponseBody() { // PrintResponseData prints the collected response data (status, headers, body) if verbose mode is enabled. // -//nolint:revive +//nolint:revive // existing signature maintained for backward compatibility func (rd ResponseData) PrintResponseData(w io.Writer, isVerbose bool) { - if !isVerbose { - return - } - - fmt.Fprintln(w, style.LgSprintf(style.ItemKey, - "- Url: %s", - style.URL.Render(rd.URL)), - ) - - fmt.Fprint(w, style.LgSprintf(style.ItemKeyP3, "StatusCode: ")) - - if rd.Error != nil { - fmt.Fprintln(w, style.LgSprintf(style.StatusError, "0")) - fmt.Fprintln(w, style.LgSprintf( - style.ItemKeyP3, - "Error: %s", - style.Error.Render(rd.Error.Error()), - )) - fmt.Fprintln(w) - + if !isVerbose || w == nil { return } - fmt.Fprintln(w, style.LgSprintf(style.Status, - "%v", - style.StatusCodeParse(rd.Response.StatusCode))) - - if rd.Request.PrintResponseCertificates { - RenderTLSData(w, rd.Response, rd.Request.ResponseCertificatesFilter) - } - - if rd.Request.PrintResponseHeaders { - headersStr := filterResponseHeaders( - rd.Response.Header, - rd.Request.ResponseHeadersFilter) - - fmt.Fprintln(w, style.LgSprintf(style.ItemKeyP3, "Headers: ")) - fmt.Fprintln(w, headersStr) - } - - if rd.Request.ResponseBodyMatchRegexp != "" { - fmt.Fprint(w, style.LgSprintf(style.ItemKeyP3, "BodyRegexpMatch: ")) - fmt.Fprintln(w, rd.ResponseBodyRegexpMatched) - } - - if rd.Request.PrintResponseBody { - fmt.Fprintln(w, style.LgSprintf(style.ItemKeyP3, "Body:")) - fmt.Fprintln(w, rd.ResponseBody) - } - - fmt.Fprintln(w) + respRes := buildResponseResult(rd) + _ = view.Render(w, SingleResponseDoc(respRes), view.Options{ForceColor: true}) } // RenderTLSData prints TLS version, cipher suite, and peer certificates for an HTTP response. // An optional filter can be provided to only print specific certificate indices and fields. func RenderTLSData(w io.Writer, r *http.Response, filter ...[]map[int][]string) { - respTLS := r.TLS - sl := style.CertKeyP4.Render - sv := style.CertValue.Render - - fmt.Fprintln(w, style.LgSprintf(style.ItemKeyP3, "TLS:")) + if r == nil { + return + } + respTLS := r.TLS if respTLS == nil { - fmt.Fprintln( - w, - style.LgSprintf(style.CertKeyP4, - "%s", - style.Error.Render("No TLS connection state available"), - ), - ) + _ = view.Render(w, view.Doc{ + Nodes: []view.Node{ + view.Section{ + Title: "TLS", + Level: 2, + Kids: []view.Node{ + view.KV{Key: "Error", Value: "No TLS connection state available", Tone: view.ToneCrit}, + }, + }, + }, + }, view.Options{ForceColor: true}) return } - t := table.New().Border(style.LGDefBorder) - t.Row( - sl("Version"), - sv(TLSVersionName(respTLS.Version)), - ) - t.Row( - sl("CipherSuite"), - sv(cipherSuiteName(respTLS.CipherSuite)), - ) - t.Row( - sl("Key Exchange"), - sv(respTLS.CurveID.String()), - ) - fmt.Fprintln(w, t.Render()) - t.ClearRows() + tlsKids := []view.Node{ + view.Table{ + Rows: [][]view.Cell{ + { + {Text: "Version", Tone: view.ToneKey}, + {Text: TLSVersionName(respTLS.Version), Tone: view.ToneValue}, + }, + { + {Text: "CipherSuite", Tone: view.ToneKey}, + {Text: cipherSuiteName(respTLS.CipherSuite), Tone: view.ToneValue}, + }, + { + {Text: "Key Exchange", Tone: view.ToneKey}, + {Text: respTLS.CurveID.String(), Tone: view.ToneValue}, + }, + }, + }, + } var f []map[int][]string if len(filter) > 0 { f = filter[0] } - certinfo.CertsToTables(w, respTLS.PeerCertificates, f) + certDoc := certinfo.CertsDoc(respTLS.PeerCertificates, f) + tlsKids = append(tlsKids, certDoc.Nodes...) + + doc := view.Doc{ + Nodes: []view.Node{ + view.Section{ + Title: "TLS:", + Level: 2, + Kids: tlsKids, + }, + }, + } + + _ = view.Render(w, doc, view.Options{ForceColor: true}) } diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go index 3b8905e..37434cf 100644 --- a/internal/requests/requests_test.go +++ b/internal/requests/requests_test.go @@ -268,7 +268,112 @@ func TestNewRequestHTTPClient(t *testing.T) { defaultCurvePreferences, transport.TLSClientConfig.CurvePreferences, "unexpected CurvePreferences") + assert.NotNil(t, + client.client.CheckRedirect, + "CheckRedirect should not be nil by default") + }) +} + +func TestRequestHTTPClient_SetFollowRedirects(t *testing.T) { + t.Parallel() + + t.Run("nil client returns nil", func(t *testing.T) { + t.Parallel() + + var c *RequestHTTPClient + assert.Nil(t, c.SetFollowRedirects(true)) + }) + + t.Run("default no follow redirects", func(t *testing.T) { + t.Parallel() + + c := NewRequestHTTPClient() + require.NotNil(t, c.client.CheckRedirect) + + err := c.client.CheckRedirect(nil, nil) + require.ErrorIs(t, err, http.ErrUseLastResponse) }) + + t.Run("enable follow redirects", func(t *testing.T) { + t.Parallel() + + c := NewRequestHTTPClient() + c.SetFollowRedirects(true) + assert.Nil(t, c.client.CheckRedirect) + }) + + t.Run("disable follow redirects after enable", func(t *testing.T) { + t.Parallel() + + c := NewRequestHTTPClient() + c.SetFollowRedirects(true) + assert.Nil(t, c.client.CheckRedirect) + + c.SetFollowRedirects(false) + require.NotNil(t, c.client.CheckRedirect) + require.ErrorIs(t, c.client.CheckRedirect(nil, nil), http.ErrUseLastResponse) + }) +} + +func TestRequestHTTPClient_FollowRedirects_Server(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/redirect": + http.Redirect(w, r, "/final", http.StatusFound) + case "/final": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("final-destination")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + // Default (no follow): should return 302 Found + clientNoFollow := NewRequestHTTPClient() + + req1, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/redirect", nil) + require.NoError(t, err) + + resp1, err := clientNoFollow.client.Do(req1) + require.NoError(t, err) + + defer resp1.Body.Close() + + assert.Equal(t, http.StatusFound, resp1.StatusCode) + + // Follow redirects: should return 200 OK + clientFollow := NewRequestHTTPClient() + clientFollow.SetFollowRedirects(true) + + req2, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/redirect", nil) + require.NoError(t, err) + + resp2, err := clientFollow.client.Do(req2) + require.NoError(t, err) + + defer resp2.Body.Close() + + assert.Equal(t, http.StatusOK, resp2.StatusCode) +} + +func TestNewHTTPClientFromRequestConfig_FollowRedirects(t *testing.T) { + t.Parallel() + + clientNoFollow, err := NewHTTPClientFromRequestConfig(RequestConfig{ + FollowRedirects: false, + }, "example.com", nil) + require.NoError(t, err) + require.NotNil(t, clientNoFollow.client.CheckRedirect) + require.ErrorIs(t, clientNoFollow.client.CheckRedirect(nil, nil), http.ErrUseLastResponse) + + clientFollow, err := NewHTTPClientFromRequestConfig(RequestConfig{ + FollowRedirects: true, + }, "example.com", nil) + require.NoError(t, err) + assert.Nil(t, clientFollow.client.CheckRedirect) } func TestNewHTTPClientFromRequestConfig_Error(t *testing.T) { diff --git a/internal/requests/result.go b/internal/requests/result.go new file mode 100644 index 0000000..c112bbc --- /dev/null +++ b/internal/requests/result.go @@ -0,0 +1,172 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package requests + +import ( + "encoding/json" + "errors" + "slices" + + "github.com/xenos76/https-wrench/internal/certinfo" +) + +const ( + // ResultSchemaVersion is the JSON export schema version for requests results. + ResultSchemaVersion = "1" + resultCommand = "requests" +) + +// Result is the serializable requests report (source of truth for JSON and console Doc). +type Result struct { + SchemaVersion string `json:"schemaVersion"` + Command string `json:"command"` + Requests []RequestResult `json:"requests"` +} + +// RequestResult represents the outcome of a configured RequestConfig. +type RequestResult struct { + Name string `json:"name"` + TransportOverrideURL string `json:"transportOverrideUrl,omitempty"` + Responses []ResponseResult `json:"responses"` +} + +// ResponseResult holds the results and metadata of a single HTTP response. +type ResponseResult struct { + URL string `json:"url"` + TransportAddress string `json:"transportAddress,omitempty"` + StatusCode int `json:"statusCode"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Headers map[string][]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` + ContentType string `json:"contentType,omitempty"` + BodyRegexpMatched *bool `json:"bodyRegexpMatched,omitempty"` + TLS *ResponseTLSResult `json:"tls,omitempty"` +} + +// ResponseTLSResult holds negotiated TLS parameters and certificate details. +type ResponseTLSResult struct { + Version string `json:"version,omitempty"` + CipherSuite string `json:"cipherSuite,omitempty"` + KeyExchange string `json:"keyExchange,omitempty"` + Certificates []certinfo.CertInfo `json:"certificates,omitempty"` + CertificatesFilter []map[int][]string `json:"certificatesFilter,omitempty"` +} + +// BuildResult gathers a serializable report from execution response data and configuration. +func BuildResult(responseMap map[string][]ResponseData, cfg *RequestsMetaConfig) (*Result, error) { + if cfg == nil { + return nil, errors.New("requests: nil meta config") + } + + seenNames := make(map[string]struct{}, len(cfg.Requests)) + for _, reqCfg := range cfg.Requests { + if _, exists := seenNames[reqCfg.Name]; exists { + return nil, &DuplicateRequestNameError{Name: reqCfg.Name} + } + + seenNames[reqCfg.Name] = struct{}{} + } + + res := &Result{ + SchemaVersion: ResultSchemaVersion, + Command: resultCommand, + Requests: make([]RequestResult, 0, len(cfg.Requests)), + } + + for _, reqCfg := range cfg.Requests { + res.Requests = append(res.Requests, buildRequestResult(reqCfg, responseMap[reqCfg.Name])) + } + + return res, nil +} + +func buildRequestResult(reqCfg RequestConfig, rdList []ResponseData) RequestResult { + reqRes := RequestResult{ + Name: reqCfg.Name, + TransportOverrideURL: reqCfg.TransportOverrideURL, + Responses: make([]ResponseResult, 0, len(rdList)), + } + + for _, rd := range rdList { + reqRes.Responses = append(reqRes.Responses, buildResponseResult(rd)) + } + + return reqRes +} + +func buildResponseResult(rd ResponseData) ResponseResult { + respRes := ResponseResult{ + URL: rd.URL, + TransportAddress: rd.TransportAddress, + } + + if rd.Error != nil { + respRes.Error = rd.Error.Error() + return respRes + } + + if rd.Response != nil { + respRes.StatusCode = rd.Response.StatusCode + respRes.Status = rd.Response.Status + + if rd.Request.PrintResponseHeaders || len(rd.Request.ResponseHeadersFilter) > 0 { + respRes.Headers = filterHeadersMap(rd.Response.Header, rd.Request.ResponseHeadersFilter) + } + + if rd.Request.ResponseBodyMatchRegexp != "" { + matched := rd.ResponseBodyRegexpMatched + respRes.BodyRegexpMatched = &matched + } + + if rd.Request.PrintResponseBody { + respRes.Body = rd.ResponseBody + respRes.ContentType = rd.ResponseContentType + } + + if rd.Request.PrintResponseCertificates && rd.Response.TLS != nil { + respTLS := rd.Response.TLS + respRes.TLS = &ResponseTLSResult{ + Version: TLSVersionName(respTLS.Version), + CipherSuite: cipherSuiteName(respTLS.CipherSuite), + KeyExchange: respTLS.CurveID.String(), + Certificates: certinfo.CertInfos(respTLS.PeerCertificates), + CertificatesFilter: rd.Request.ResponseCertificatesFilter, + } + } + } + + return respRes +} + +// EncodeJSON writes the result as indented JSON with no ANSI escape sequences. +func EncodeJSON(r *Result) ([]byte, error) { + if r == nil { + return nil, errors.New("requests: nil result") + } + + return json.MarshalIndent(r, "", " ") +} + +func filterHeadersMap(headers map[string][]string, filter []string) map[string][]string { + if len(filter) == 0 { + out := make(map[string][]string, len(headers)) + for k, v := range headers { + out[k] = append([]string(nil), v...) + } + + return out + } + + out := make(map[string][]string) + + for k, v := range headers { + if slices.Contains(filter, k) { + out[k] = append([]string(nil), v...) + } + } + + return out +} diff --git a/internal/requests/result_test.go b/internal/requests/result_test.go new file mode 100644 index 0000000..07693aa --- /dev/null +++ b/internal/requests/result_test.go @@ -0,0 +1,306 @@ +package requests + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "errors" + "io" + "math/big" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xenos76/https-wrench/internal/view" +) + +func sampleResultData() (*RequestsMetaConfig, map[string][]ResponseData) { + cfg := &RequestsMetaConfig{ + Requests: []RequestConfig{ + { + Name: "test-req", + TransportOverrideURL: "https://127.0.0.1:8443", + PrintResponseHeaders: true, + PrintResponseBody: true, + PrintResponseCertificates: true, + ResponseBodyMatchRegexp: "ok", + }, + }, + } + + mockCert := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test.example.com"}, + SerialNumber: big.NewInt(12345), + } + + matched := true + responseMap := map[string][]ResponseData{ + "test-req": { + { + URL: "https://example.com/api", + TransportAddress: "127.0.0.1:8443", + Request: cfg.Requests[0], + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Server": []string{"mock-server"}, + }, + TLS: &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + PeerCertificates: []*x509.Certificate{mockCert}, + }, + }, + ResponseBody: "{\"status\":\"ok\"}", + ResponseContentType: "json", + ResponseBodyRegexpMatched: matched, + }, + { + URL: "https://example.com/fail", + TransportAddress: "127.0.0.1:8443", + Request: cfg.Requests[0], + Error: errors.New("connection reset"), + }, + }, + } + + return cfg, responseMap +} + +func TestRequests_BuildResult(t *testing.T) { + t.Parallel() + + cfg, responseMap := sampleResultData() + + result, err := BuildResult(responseMap, cfg) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, ResultSchemaVersion, result.SchemaVersion) + assert.Equal(t, resultCommand, result.Command) + require.Len(t, result.Requests, 1) + + reqRes := result.Requests[0] + assert.Equal(t, "test-req", reqRes.Name) + assert.Equal(t, "https://127.0.0.1:8443", reqRes.TransportOverrideURL) + require.Len(t, reqRes.Responses, 2) + + // Successful response assertions + okResp := reqRes.Responses[0] + assert.Equal(t, "https://example.com/api", okResp.URL) + assert.Equal(t, 200, okResp.StatusCode) + assert.Equal(t, "200 OK", okResp.Status) + assert.Empty(t, okResp.Error) + assert.NotNil(t, okResp.BodyRegexpMatched) + assert.True(t, *okResp.BodyRegexpMatched) + assert.JSONEq(t, "{\"status\":\"ok\"}", okResp.Body) + assert.Equal(t, "json", okResp.ContentType) + require.NotNil(t, okResp.TLS) + assert.Equal(t, "TLS 1.3", okResp.TLS.Version) + assert.Equal(t, "TLS_AES_128_GCM_SHA256", okResp.TLS.CipherSuite) + require.Len(t, okResp.TLS.Certificates, 1) + assert.Contains(t, okResp.TLS.Certificates[0].Subject, "test.example.com") + assert.Contains(t, okResp.Headers, "Content-Type") + + // Error response assertions + errResp := reqRes.Responses[1] + assert.Equal(t, "https://example.com/fail", errResp.URL) + assert.Equal(t, "connection reset", errResp.Error) + assert.Equal(t, 0, errResp.StatusCode) +} + +func TestRequests_EncodeJSON(t *testing.T) { + t.Parallel() + + cfg, responseMap := sampleResultData() + result, err := BuildResult(responseMap, cfg) + require.NoError(t, err) + + payload, err := EncodeJSON(result) + require.NoError(t, err) + require.NotEmpty(t, payload) + + assert.NotContains(t, string(payload), "\x1b[", "JSON must not contain ANSI escape codes") + + var unmarshaled map[string]any + require.NoError(t, json.Unmarshal(payload, &unmarshaled)) + assert.Equal(t, "1", unmarshaled["schemaVersion"]) + assert.Equal(t, "requests", unmarshaled["command"]) +} + +func TestRequests_BuildDoc(t *testing.T) { + t.Parallel() + + cfg, responseMap := sampleResultData() + result, err := BuildResult(responseMap, cfg) + require.NoError(t, err) + + doc := BuildDoc(result) + + var buf bytes.Buffer + + err = view.Render(&buf, doc, view.Options{Plain: true}) + require.NoError(t, err) + + plainOutput := buf.String() + assert.Contains(t, plainOutput, "Requests") + assert.Contains(t, plainOutput, "Request: test-req") + assert.Contains(t, plainOutput, "https://example.com/api") + assert.Contains(t, plainOutput, "StatusCode: 200 OK") + assert.Contains(t, plainOutput, "TLS") + assert.Contains(t, plainOutput, "Headers") + assert.Contains(t, plainOutput, "BodyRegexpMatch: true") + assert.Contains(t, plainOutput, "connection reset") + assert.NotContains(t, plainOutput, "\x1b[", "Plain view output must not contain ANSI escape codes") +} + +func TestRequests_BuildResultErrors(t *testing.T) { + t.Parallel() + + _, err := BuildResult(nil, nil) + require.Error(t, err) + + _, err = EncodeJSON(nil) + require.Error(t, err) +} + +func TestRequests_BuildResult_ResponseBodyNotPrintedByDefault(t *testing.T) { + t.Parallel() + + cfg := &RequestsMetaConfig{ + Requests: []RequestConfig{ + { + Name: "regex-only", + PrintResponseBody: false, + ResponseBodyMatchRegexp: "ok", + }, + }, + } + + matched := true + responseMap := map[string][]ResponseData{ + "regex-only": { + { + URL: "https://example.com/api", + Request: cfg.Requests[0], + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + }, + ResponseBody: "{\"status\":\"ok\"}", + ResponseContentType: "json", + ResponseBodyRegexpMatched: matched, + }, + }, + } + + result, err := BuildResult(responseMap, cfg) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Requests, 1) + require.Len(t, result.Requests[0].Responses, 1) + + resp := result.Requests[0].Responses[0] + assert.Empty(t, resp.Body) + assert.NotNil(t, resp.BodyRegexpMatched) + assert.True(t, *resp.BodyRegexpMatched) + + doc := BuildDoc(result) + + var buf bytes.Buffer + + require.NoError(t, view.Render(&buf, doc, view.Options{Plain: true})) + assert.Contains(t, buf.String(), "BodyRegexpMatch: true") + assert.NotContains(t, buf.String(), "Body:") +} + +func TestRequests_BuildResult_DuplicateRequestName(t *testing.T) { + t.Parallel() + + cfg := &RequestsMetaConfig{ + Requests: []RequestConfig{ + {Name: "duplicate-name"}, + {Name: "duplicate-name"}, + }, + } + + _, err := BuildResult(nil, cfg) + require.Error(t, err) + require.ErrorIs(t, err, ErrDuplicateRequestName) + + var dupErr *DuplicateRequestNameError + require.ErrorAs(t, err, &dupErr) + assert.Equal(t, "duplicate-name", dupErr.Name) +} + +func TestRequests_ExecuteWithWriter_DuplicateRequestName(t *testing.T) { + t.Parallel() + + rmc, err := NewRequestsMetaConfig() + require.NoError(t, err) + + rmc.Requests = []RequestConfig{ + {Name: "same-name"}, + {Name: "same-name"}, + } + + _, _, err = rmc.ExecuteWithWriter(context.Background(), io.Discard) + require.Error(t, err) + require.ErrorIs(t, err, ErrDuplicateRequestName) + + var dupErr *DuplicateRequestNameError + require.ErrorAs(t, err, &dupErr) + assert.Equal(t, "same-name", dupErr.Name) +} + +func TestRequests_SingleResponseDoc_StatusTones(t *testing.T) { + t.Parallel() + + tests := []struct { + statusCode int + statusStr string + err string + expectedTone view.Tone + }{ + {statusCode: 200, statusStr: "200 OK", expectedTone: view.ToneStatus2xx}, + {statusCode: 204, statusStr: "204 No Content", expectedTone: view.ToneStatus2xx}, + {statusCode: 301, statusStr: "301 Moved Permanently", expectedTone: view.ToneStatus3xx}, + {statusCode: 302, statusStr: "302 Found", expectedTone: view.ToneStatus3xx}, + {statusCode: 400, statusStr: "400 Bad Request", expectedTone: view.ToneStatus4xx}, + {statusCode: 404, statusStr: "404 Not Found", expectedTone: view.ToneStatus4xx}, + {statusCode: 500, statusStr: "500 Internal Server Error", expectedTone: view.ToneStatus5xx}, + {statusCode: 503, statusStr: "503 Service Unavailable", expectedTone: view.ToneStatus5xx}, + {statusCode: 0, err: "dial tcp: connection refused", expectedTone: view.ToneStatus5xx}, + } + + for _, tc := range tests { + tt := tc + t.Run(tt.statusStr, func(t *testing.T) { + t.Parallel() + + doc := SingleResponseDoc(ResponseResult{ + StatusCode: tt.statusCode, + Status: tt.statusStr, + Error: tt.err, + }) + + var foundKV *view.KV + + for _, node := range doc.Nodes { + if kv, ok := node.(view.KV); ok && kv.Key == "StatusCode" { + foundKV = &kv + break + } + } + + require.NotNil(t, foundKV) + assert.Equal(t, tt.expectedTone, foundKV.Tone) + }) + } +} diff --git a/internal/requests/view.go b/internal/requests/view.go new file mode 100644 index 0000000..a7ce7da --- /dev/null +++ b/internal/requests/view.go @@ -0,0 +1,212 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package requests + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/xenos76/https-wrench/internal/certinfo" + "github.com/xenos76/https-wrench/internal/view" +) + +// DocOptions customizes view document rendering. +type DocOptions struct { + WithoutBanner bool +} + +// BuildDoc constructs a console view document from a Result. +func BuildDoc(r *Result) view.Doc { + return BuildDocWithOptions(r, DocOptions{}) +} + +// BuildDocWithOptions constructs a console view document with custom options. +func BuildDocWithOptions(r *Result, opts DocOptions) view.Doc { + nodes := make([]view.Node, 0, 8) + if !opts.WithoutBanner { + nodes = append(nodes, + view.Blank{}, + view.Banner{Text: "Requests"}, + view.Blank{}, + ) + } + + if r == nil { + return view.Doc{Nodes: nodes} + } + + for _, req := range r.Requests { + reqKids := make([]view.Node, 0, len(req.Responses)*4) + + if req.TransportOverrideURL != "" { + reqKids = append(reqKids, view.KV{ + Key: "Via", + Value: req.TransportOverrideURL, + Tone: view.ToneURL, + }) + } + + for _, resp := range req.Responses { + reqKids = append(reqKids, responseNodes(resp)...) + } + + nodes = append(nodes, view.Section{ + Title: fmt.Sprintf("Request: %s", req.Name), + Level: 1, + Kids: reqKids, + }) + } + + return view.Doc{Nodes: nodes} +} + +// SingleResponseDoc builds a view document for a single response result. +func SingleResponseDoc(resp ResponseResult) view.Doc { + return view.Doc{Nodes: responseNodes(resp)} +} + +func responseNodes(resp ResponseResult) []view.Node { + kids := make([]view.Node, 0, 8) + + kids = append(kids, view.KV{ + Key: "- Url", + Value: resp.URL, + Tone: view.ToneURL, + }) + + kids = append(kids, statusNodes(resp)...) + + if resp.TLS != nil { + kids = append(kids, tlsSection(resp.TLS)) + } + + if len(resp.Headers) > 0 { + kids = append(kids, headerSection(resp.Headers)) + } + + if node := bodyRegexpNode(resp); node != nil { + kids = append(kids, node) + } + + if resp.Body != "" { + kids = append(kids, view.Section{ + Title: "Body:", + Level: 2, + Kids: []view.Node{ + view.Code{ + Lang: resp.ContentType, + Body: resp.Body, + }, + }, + }) + } + + return kids +} + +func statusNodes(resp ResponseResult) []view.Node { + if resp.Error != "" { + return []view.Node{ + view.KV{Key: "StatusCode", Value: "0", Tone: view.ToneStatus5xx}, + view.KV{Key: "Error", Value: resp.Error, Tone: view.ToneCrit}, + } + } + + statusStr := strconv.Itoa(resp.StatusCode) + if resp.Status != "" { + statusStr = resp.Status + } + + var statusTone view.Tone + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + statusTone = view.ToneStatus2xx + case resp.StatusCode >= 300 && resp.StatusCode < 400: + statusTone = view.ToneStatus3xx + case resp.StatusCode >= 400 && resp.StatusCode < 500: + statusTone = view.ToneStatus4xx + case resp.StatusCode >= 500 && resp.StatusCode < 600: + statusTone = view.ToneStatus5xx + default: + statusTone = view.ToneStatus5xx + } + + return []view.Node{ + view.KV{ + Key: "StatusCode", + Value: statusStr, + Tone: statusTone, + }, + } +} + +func tlsSection(tlsInfo *ResponseTLSResult) view.Node { + tlsKids := []view.Node{ + view.Table{ + Rows: [][]view.Cell{ + {{Text: "Version", Tone: view.ToneKey}, {Text: tlsInfo.Version, Tone: view.ToneValue}}, + {{Text: "CipherSuite", Tone: view.ToneKey}, {Text: tlsInfo.CipherSuite, Tone: view.ToneValue}}, + {{Text: "Key Exchange", Tone: view.ToneKey}, {Text: tlsInfo.KeyExchange, Tone: view.ToneValue}}, + }, + }, + } + + certDoc := certinfo.CertInfosDoc(tlsInfo.Certificates, tlsInfo.CertificatesFilter) + tlsKids = append(tlsKids, certDoc.Nodes...) + + return view.Section{ + Title: "TLS:", + Level: 2, + Kids: tlsKids, + } +} + +func headerSection(headers map[string][]string) view.Node { + headerRows := make([][]view.Cell, 0, len(headers)) + keys := make([]string, 0, len(headers)) + + for k := range headers { + keys = append(keys, k) + } + + slices.Sort(keys) + + for _, k := range keys { + vals := strings.Join(headers[k], ", ") + headerRows = append(headerRows, []view.Cell{ + {Text: k, Tone: view.ToneKey}, + {Text: vals, Tone: view.ToneValue}, + }) + } + + return view.Section{ + Title: "Headers:", + Level: 2, + Kids: []view.Node{view.Table{Rows: headerRows}}, + } +} + +func bodyRegexpNode(resp ResponseResult) view.Node { + if resp.BodyRegexpMatched == nil { + return nil + } + + matchTone := view.ToneBoolFalse + matchVal := "false" + + if *resp.BodyRegexpMatched { + matchTone = view.ToneBoolTrue + matchVal = "true" + } + + return view.KV{ + Key: "BodyRegexpMatch", + Value: matchVal, + Tone: matchTone, + } +} diff --git a/internal/style/style.go b/internal/style/style.go index 485b89f..d3eb350 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -110,11 +110,11 @@ var ( // Status3xx is the style for 3xx HTTP status codes. Status3xx = lipgloss.NewStyle(). - Foreground(catMauve) + Foreground(catYellow) // Status4xx is the style for 4xx HTTP status codes. Status4xx = lipgloss.NewStyle(). - Foreground(catYellow) + Foreground(catPeach) // Status5xx is the style for 5xx HTTP status codes. Status5xx = lipgloss.NewStyle(). diff --git a/internal/view/doc.go b/internal/view/doc.go index 35ebd34..1b0103f 100644 --- a/internal/view/doc.go +++ b/internal/view/doc.go @@ -34,6 +34,14 @@ const ( ToneValue // ToneHeader styles table column headers. ToneHeader + // ToneStatus2xx styles 2xx HTTP status codes (success). + ToneStatus2xx + // ToneStatus3xx styles 3xx HTTP status codes (redirection). + ToneStatus3xx + // ToneStatus4xx styles 4xx HTTP status codes (client error). + ToneStatus4xx + // ToneStatus5xx styles 5xx HTTP status codes (server error). + ToneStatus5xx ) // Node is a view document node. diff --git a/internal/view/render.go b/internal/view/render.go index 535e906..9300ec8 100644 --- a/internal/view/render.go +++ b/internal/view/render.go @@ -10,6 +10,7 @@ import ( "os" "strings" + "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" "github.com/xenos76/https-wrench/internal/style" "golang.org/x/term" @@ -234,28 +235,51 @@ func (r renderer) paint(text string, tone Tone) string { return text } + return toneStyle(tone).Render(text) +} + +func toneStyle(tone Tone) lipgloss.Style { + if st, ok := statusToneStyle(tone); ok { + return st + } + switch tone { case ToneWarn: - return style.Warn.Render(text) + return style.Warn case ToneCrit: - return style.Crit.Render(text) + return style.Crit case ToneBoolTrue: - return style.BoolTrue.Render(text) + return style.BoolTrue case ToneBoolFalse: - return style.BoolFalse.Render(text) + return style.BoolFalse case ToneNotice: - return style.CertValueNotice.Render(text) + return style.CertValueNotice case ToneURL: - return style.URL.Render(text) + return style.URL case ToneCmd: - return style.Cmd.Render(text) + return style.Cmd case ToneSection: - return style.ItemKey.Render(text) + return style.ItemKey case ToneKey: - return style.CertKeyP4.Render(text) + return style.CertKeyP4 case ToneHeader: - return style.CertKeyP4.Bold(true).Render(text) + return style.CertKeyP4.Bold(true) + default: + return style.CertValue + } +} + +func statusToneStyle(tone Tone) (lipgloss.Style, bool) { + switch tone { + case ToneStatus2xx: + return style.Status2xx, true + case ToneStatus3xx: + return style.Status3xx, true + case ToneStatus4xx: + return style.Status4xx, true + case ToneStatus5xx: + return style.Status5xx, true default: - return style.CertValue.Render(text) + return lipgloss.Style{}, false } } diff --git a/internal/view/render_test.go b/internal/view/render_test.go index 419c3fd..11c70da 100644 --- a/internal/view/render_test.go +++ b/internal/view/render_test.go @@ -86,3 +86,21 @@ func TestRender_Code_ForceColor(t *testing.T) { require.True(t, strings.HasSuffix(got, "\n")) require.False(t, strings.HasSuffix(got, "\n\n")) } + +func TestRender_StatusTones_ForceColor(t *testing.T) { + t.Parallel() + + tones := []Tone{ToneStatus2xx, ToneStatus3xx, ToneStatus4xx, ToneStatus5xx} + for _, tone := range tones { + doc := Doc{Nodes: []Node{ + KV{Key: "StatusCode", Value: "test", Tone: tone}, + }} + + var buf bytes.Buffer + require.NoError(t, Render(&buf, doc, Options{ForceColor: true})) + + got := buf.String() + require.Contains(t, got, "StatusCode") + require.Contains(t, got, "test") + } +}