diff --git a/.changes/unreleased/BUG FIXES-20260713-133938.yaml b/.changes/unreleased/BUG FIXES-20260713-133938.yaml new file mode 100644 index 0000000..1a01b87 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260713-133938.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Fix `api` `--json` and `--jq` output incorrectly including one-to-one relationship IDs under `attributes`. Those IDs are surfaced for table and pretty output only, and are no longer written back into the raw JSON payload. +time: 2026-07-13T13:39:38-04:00 diff --git a/internal/pkg/format/jsonapi.go b/internal/pkg/format/jsonapi.go index 8b3db12..29a9ee6 100644 --- a/internal/pkg/format/jsonapi.go +++ b/internal/pkg/format/jsonapi.go @@ -220,6 +220,14 @@ func resourceAsMap(item any) (map[string]any, bool) { return nil, false } + // Copy attributes into the row. We must not mutate attrMap directly: it is + // shared with the raw payload that backs --json / --jq output, and pulling + // relationship IDs into it would pollute that output with keys the server + // never returned under "attributes". + for key, value := range attrMap { + row[key] = value + } + // Look for one-to-one relationships and pull the ID up to the top level of the row for display. rels, ok := obj["relationships"] if ok { @@ -233,14 +241,11 @@ func resourceAsMap(item any) (map[string]any, bool) { continue } if oneToOne, isOneToOne := data.(map[string]any); isOneToOne { - attrMap[rel] = oneToOne["id"] + row[rel] = oneToOne["id"] } } } - for key, value := range attrMap { - row[key] = value - } flattenRow(row) return row, true } diff --git a/internal/pkg/format/jsonapi_mutation_test.go b/internal/pkg/format/jsonapi_mutation_test.go new file mode 100644 index 0000000..ac3f6b6 --- /dev/null +++ b/internal/pkg/format/jsonapi_mutation_test.go @@ -0,0 +1,98 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" +) + +// A JSON:API envelope with a one-to-one relationship. The displayer pulls the +// relationship ID up into the flattened rows for table/pretty display, but it +// must not mutate the raw envelope that feeds --json / --jq output. +const relEnvelope = `{ + "data": [ + { + "id": "ws-abc123", + "type": "workspaces", + "attributes": { + "name": "my-workspace", + "auto-apply": false + }, + "relationships": { + "organization": { + "data": {"id": "org-xyz789", "type": "organizations"} + } + } + } + ] +}` + +// TestJSONAPI_JSONOutput_NotPollutedByRelationshipID asserts that JSON output +// reflects the server payload exactly: the relationship ID must NOT be injected +// into data[].attributes. +func TestJSONAPI_JSONOutput_NotPollutedByRelationshipID(t *testing.T) { + t.Parallel() + r := require.New(t) + + disp, err := format.NewJSONAPIDisplayer([]byte(relEnvelope), hclog.Default()) + r.NoError(err) + + io := iostreams.Test() + out := format.New(io) + out.SetFormat(format.JSON) + r.NoError(out.Display(disp)) + + var parsed struct { + Data []struct { + Attributes map[string]any `json:"attributes"` + } `json:"data"` + } + r.NoError(json.Unmarshal(io.Output.Bytes(), &parsed)) + r.Len(parsed.Data, 1) + + attrs := parsed.Data[0].Attributes + r.Contains(attrs, "name", "real attributes should survive") + r.NotContains(attrs, "organization", + "relationship ID must not be injected into attributes in JSON output; got %v", attrs) +} + +// TestJSONAPI_PayloadStableAcrossFormats asserts the raw payload is not mutated +// as a side effect of rendering a table (which pulls relationship IDs up). +func TestJSONAPI_PayloadStableAcrossFormats(t *testing.T) { + t.Parallel() + r := require.New(t) + + disp, err := format.NewJSONAPIDisplayer([]byte(relEnvelope), hclog.Default()) + r.NoError(err) + + // Render as a table first (this is what triggers the relationship pull-up). + tableIO := iostreams.Test() + tableOut := format.New(tableIO) + tableOut.SetFormat(format.Table) + r.NoError(tableOut.Display(disp)) + + // Now render JSON from the same displayer and confirm attributes are clean. + jsonIO := iostreams.Test() + jsonOut := format.New(jsonIO) + jsonOut.SetFormat(format.JSON) + r.NoError(jsonOut.Display(disp)) + + var parsed struct { + Data []struct { + Attributes map[string]any `json:"attributes"` + } `json:"data"` + } + r.NoError(json.Unmarshal(jsonIO.Output.Bytes(), &parsed)) + r.Len(parsed.Data, 1) + r.NotContains(parsed.Data[0].Attributes, "organization", + "raw payload was mutated by table rendering; attributes = %v", parsed.Data[0].Attributes) +}