From b73710f1ca305b071661f0da9f8f15c76eb83cd3 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:27:42 -0700 Subject: [PATCH] Add response header expectations Add a repeatable --expect-header flag that checks response headers after output is written. Document the flag and cover parsing, success, mismatch, and redaction cases in unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 + cli/docs/cli-reference.md | 15 ++ cli/src/internal/cmd/root.go | 3 + cli/src/internal/cmd/root_test.go | 12 ++ cli/src/internal/config/config.go | 1 + cli/src/internal/service/expect_header.go | 105 +++++++++++++ .../internal/service/expect_header_test.go | 147 ++++++++++++++++++ cli/src/internal/service/service.go | 8 + web/src/pages/reference.astro | 14 ++ 9 files changed, 309 insertions(+) create mode 100644 cli/src/internal/service/expect_header.go create mode 100644 cli/src/internal/service/expect_header_test.go diff --git a/README.md b/README.md index e3157eb..d1cfcb4 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,10 @@ azd rest put https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 \ --flatten +# Fail if a required response header is missing or has a different value +azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \ + --expect-header "Content-Type=application/json" + # Diagnose authentication issues azd rest doctor diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index b7a5f27..310aab3 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -205,6 +205,7 @@ These flags are available for all HTTP method commands: | `--redact` | | string[] | [] | Mask a JSON response field before output (repeatable, dotted path, `*` matches array elements). | | `--binary` | | bool | false | Stream request/response as binary without transformation. | | `--include` | `-i` | bool | false | Include the HTTP status line and response headers in the output (curl `-i` style). Sensitive header values are redacted. | +| `--expect-header` | | string[] | [] | Require a response header, optionally with an exact value. Repeatable. Formats: `Name`, `Name=value`, or `Name: value`. | | `--verbose` | `-v` | bool | false | Verbose output (show headers, timing, request details). | | `--silent` | | bool | false | Suppress non-error diagnostic messages on stderr (warnings and notices). Errors and response output are unaffected. | @@ -677,6 +678,20 @@ x-ms-request-id: 6f1c... Sensitive header values (for example `Authorization` and cookies) are redacted. Unlike `--verbose`, which writes request diagnostics and timing to stderr, `--include` writes only the status line and response headers alongside the body on stdout, which is convenient for scripts that need a header such as `Location`, `ETag`, or `x-ms-request-id`. `--include` works with the `auto`, `json`, and `raw` formats and with binary responses. +## Expect Response Headers + +Use `--expect-header` to fail the command when a response does not include a required header or exact header value. Header names are matched case-insensitively, and sensitive header values are redacted in error messages. + +```bash +# Require a response header +azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \ + --expect-header Content-Type + +# Require an exact response header value +azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \ + --expect-header "Content-Type=application/json" +``` + ## Silent Mode Use `--silent` to suppress non-error diagnostic messages that `azd rest` writes to stderr. This covers the insecure TLS warning, the "no scope found" warning, and the pagination notice. Errors, exit codes, and the response body on stdout are unaffected, so you never lose a genuine failure by silencing diagnostics. diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..0e3df9c 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -61,6 +61,7 @@ var ( redactPaths []string tableColumns []string dumpHeaders string + expectedHeaders []string fail bool rawOutput bool compact bool @@ -226,6 +227,7 @@ Examples: rootCmd.PersistentFlags().StringArrayVar(&redactPaths, "redact", []string{}, "Mask a JSON response field before output (repeatable, dotted path, * matches array elements)") rootCmd.PersistentFlags().StringSliceVar(&tableColumns, "table-columns", nil, "Comma-separated columns to show, in order, for --format table (ignored for other formats)") rootCmd.PersistentFlags().StringVar(&dumpHeaders, "dump-headers", "", "Write response status line and headers to a file (use - for stderr)") + rootCmd.PersistentFlags().StringArrayVar(&expectedHeaders, "expect-header", []string{}, "Require a response header, optionally with an exact value (repeatable; formats: Name, Name=value, or Name: value)") rootCmd.PersistentFlags().BoolVar(&fail, "fail", false, "Exit with code 22 when the response status is 400 or higher (the response body is still printed)") rootCmd.PersistentFlags().BoolVarP(&rawOutput, "raw-output", "r", false, "With --query, print a string result unquoted and an array of strings one per line (like jq -r)") rootCmd.PersistentFlags().BoolVarP(&compact, "compact", "c", false, "Minify JSON output to a single line (applies to auto and json formats and --query results)") @@ -301,6 +303,7 @@ func snapshotConfig() config.Config { Redact: redactPaths, TableColumns: tableColumns, DumpHeaders: dumpHeaders, + ExpectedHeaders: expectedHeaders, Fail: fail, RawOutput: rawOutput, Compact: compact, diff --git a/cli/src/internal/cmd/root_test.go b/cli/src/internal/cmd/root_test.go index a1268be..8abb2d0 100644 --- a/cli/src/internal/cmd/root_test.go +++ b/cli/src/internal/cmd/root_test.go @@ -36,11 +36,15 @@ func resetGlobalFlags() { headerFile = "" data = "" dataFile = "" + dataFormat = "json" query = "" formFields = []string{} + jsonFields = []string{} + jsonFieldsRaw = []string{} outputFile = "" outputFormat = defaults.OutputFormat verbose = false + flatten = false paginate = false retry = defaults.Retry binary = false @@ -54,9 +58,17 @@ func resetGlobalFlags() { maxResponseSize = defaults.MaxResponseSize showThrottle = false repeat = defaults.Repeat + colorMode = defaults.Color writeOut = "" include = false allowHosts = []string{} + redactPaths = []string{} + tableColumns = nil + dumpHeaders = "" + expectedHeaders = []string{} + fail = false + rawOutput = false + compact = false } func TestNewRootCmd(t *testing.T) { diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..1268e9b 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { Redact []string TableColumns []string DumpHeaders string + ExpectedHeaders []string Fail bool RawOutput bool Compact bool diff --git a/cli/src/internal/service/expect_header.go b/cli/src/internal/service/expect_header.go new file mode 100644 index 0000000..56ccb75 --- /dev/null +++ b/cli/src/internal/service/expect_header.go @@ -0,0 +1,105 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/jongio/azd-rest/src/internal/client" +) + +type headerExpectation struct { + name string + value string + hasValue bool + original string + separator string +} + +func parseHeaderExpectation(raw string) (headerExpectation, error) { + text := strings.TrimSpace(raw) + if text == "" { + return headerExpectation{}, fmt.Errorf("--expect-header cannot be empty") + } + + separatorIndex := -1 + separator := "" + for _, candidate := range []string{"=", ":"} { + if idx := strings.Index(text, candidate); idx >= 0 && (separatorIndex == -1 || idx < separatorIndex) { + separatorIndex = idx + separator = candidate + } + } + + if separatorIndex == -1 { + return headerExpectation{name: text, original: raw}, nil + } + + name := strings.TrimSpace(text[:separatorIndex]) + if name == "" { + return headerExpectation{}, fmt.Errorf("invalid --expect-header %q: header name is required", raw) + } + + return headerExpectation{ + name: name, + value: strings.TrimSpace(text[separatorIndex+1:]), + hasValue: true, + original: raw, + separator: separator, + }, nil +} + +func checkExpectedHeaders(resp *client.Response, expectations []string) error { + for _, raw := range expectations { + exp, err := parseHeaderExpectation(raw) + if err != nil { + return err + } + + values := resp.Headers.Values(exp.name) + if len(values) == 0 { + return fmt.Errorf("expected response header %q to be present", exp.name) + } + if !exp.hasValue { + continue + } + + matched := false + for _, actual := range values { + if actual == exp.value { + matched = true + break + } + } + if matched { + continue + } + + return fmt.Errorf( + "expected response header %q to equal %q, got %s", + exp.name, + client.RedactSensitiveHeader(exp.name, exp.value), + redactedHeaderValues(exp.name, values), + ) + } + return nil +} + +func redactedHeaderValues(name string, values []string) string { + if len(values) == 0 { + return "" + } + redacted := make([]string, 0, len(values)) + for _, value := range values { + redacted = append(redacted, fmt.Sprintf("%q", client.RedactSensitiveHeader(name, value))) + } + return strings.Join(redacted, ", ") +} + +func validateHeaderExpectations(expectations []string) error { + for _, raw := range expectations { + if _, err := parseHeaderExpectation(raw); err != nil { + return err + } + } + return nil +} diff --git a/cli/src/internal/service/expect_header_test.go b/cli/src/internal/service/expect_header_test.go new file mode 100644 index 0000000..ef6327f --- /dev/null +++ b/cli/src/internal/service/expect_header_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/jongio/azd-rest/src/internal/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseHeaderExpectation(t *testing.T) { + tests := []struct { + name string + raw string + want headerExpectation + wantErr string + }{ + { + name: "presence only", + raw: "ETag", + want: headerExpectation{name: "ETag", original: "ETag"}, + }, + { + name: "equals value", + raw: "Content-Type=application/json", + want: headerExpectation{name: "Content-Type", value: "application/json", hasValue: true, original: "Content-Type=application/json", separator: "="}, + }, + { + name: "colon value", + raw: "Content-Type: application/json", + want: headerExpectation{name: "Content-Type", value: "application/json", hasValue: true, original: "Content-Type: application/json", separator: ":"}, + }, + { + name: "empty", + raw: " ", + wantErr: "cannot be empty", + }, + { + name: "missing name", + raw: "=application/json", + wantErr: "header name is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseHeaderExpectation(tt.raw) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCheckExpectedHeaders(t *testing.T) { + resp := &client.Response{ + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "Etag": []string{`"v1"`}, + }, + } + + require.NoError(t, checkExpectedHeaders(resp, []string{"content-type"})) + require.NoError(t, checkExpectedHeaders(resp, []string{"Content-Type=application/json"})) + require.NoError(t, checkExpectedHeaders(resp, []string{"Content-Type: application/json"})) + + err := checkExpectedHeaders(resp, []string{"X-Request-Id"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "X-Request-Id") + + err = checkExpectedHeaders(resp, []string{"Content-Type=text/plain"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "application/json") +} + +func TestCheckExpectedHeaders_RedactsSensitiveValues(t *testing.T) { + resp := &client.Response{ + Headers: http.Header{ + "Set-Cookie": []string{"session=super-secret-cookie-value"}, + }, + } + + err := checkExpectedHeaders(resp, []string{"Set-Cookie=expected-secret-cookie"}) + require.Error(t, err) + assert.NotContains(t, err.Error(), "super-secret-cookie-value") + assert.NotContains(t, err.Error(), "expected-secret-cookie") +} + +func TestExecute_ExpectHeaderMismatchWritesBodyThenReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := baseTestConfig(t) + cfg.ExpectedHeaders = []string{"Content-Type=text/plain"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "Content-Type") + + out, readErr := os.ReadFile(cfg.OutputFile) + require.NoError(t, readErr) + assert.Contains(t, string(out), `"ok": true`) +} + +func TestExecute_ExpectHeaderPasses(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Request-Id", "req-123") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + cfg := baseTestConfig(t) + cfg.ExpectedHeaders = []string{"x-request-id=req-123"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL) + require.NoError(t, err) +} + +func TestExecute_ExpectHeaderParseError(t *testing.T) { + cfg := baseTestConfig(t) + cfg.ExpectedHeaders = []string{"=value"} + + err := newTestService().Execute(context.Background(), cfg, "GET", "http://127.0.0.1") + require.Error(t, err) + assert.Contains(t, err.Error(), "header name is required") + assert.False(t, errors.Is(err, http.ErrServerClosed)) +} + +func TestRedactedHeaderValues(t *testing.T) { + got := redactedHeaderValues("X-Test", []string{"one", "two"}) + assert.True(t, strings.Contains(got, `"one"`)) + assert.True(t, strings.Contains(got, `"two"`)) +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..0a172ff 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -397,6 +397,10 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, return &rawOutputUsageError{msg: "--raw-output requires --query"} } + if err := validateHeaderExpectations(cfg.ExpectedHeaders); err != nil { + return err + } + // Echo the correlation ID so it can be quoted in an Azure support request. if cfg.ClientRequestID != "" { fmt.Fprintf(os.Stderr, "%s: %s\n", clientRequestIDHeader, cfg.ClientRequestID) @@ -460,6 +464,10 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, fmt.Fprint(os.Stderr, ExpandWriteOut(cfg.WriteOut, opts.Method, opts.URL, resp)) } + if err := checkExpectedHeaders(resp, cfg.ExpectedHeaders); err != nil { + return err + } + // --fail (#233): after the body and metadata have been written, return a // non-zero exit for an error status so scripts and CI can detect failures. if cfg.Fail && resp.StatusCode >= 400 { diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..a6182a3 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -103,6 +103,7 @@ const base = import.meta.env.BASE_URL; --redactstring[][]Mask a JSON response field before output (repeatable). Dotted path, * matches array elements. Applies to json, auto, table, and jsonl. --flattenboolfalseFlatten a JSON response into a single-level object keyed by dotted paths (properties.provisioningState, value[0].name). Leaf values keep their type. Applies to the json and auto output paths and composes with --query. --dump-headersstring""Write response status line and headers to a file (use - for stderr). Sensitive values are redacted. + --expect-headerstring[][]Require a response header, optionally with an exact value. Repeatable. Formats: Name, Name=value, or Name: value. --binaryboolfalseStream as binary without transformation --verbose-vboolfalseShow headers, timing, request details (tokens redacted) --colorstringautoColorize JSON output: auto (TTY only, honors NO_COLOR), always, never @@ -163,6 +164,19 @@ azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \ # Status and timing on one line azd rest get https://graph.microsoft.com/v1.0/me \\ --write-out "%{http_code} %{time_total}s %{size_download}B\\n"`} language="bash" /> + +

Response Header Checks

+

+ Use --expect-header in scripts to fail the command when a response + does not include a required header or value. Header names are matched case-insensitively. +

+