Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260713-133938.yaml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 9 additions & 4 deletions internal/pkg/format/jsonapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
98 changes: 98 additions & 0 deletions internal/pkg/format/jsonapi_mutation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}