From f5a7bdd161ad3929467962fe4e7210ce7ac36db0 Mon Sep 17 00:00:00 2001 From: Bjorn Tipling Date: Mon, 24 Aug 2026 22:36:19 +0000 Subject: [PATCH 1/2] Add regression tests for integer:"string" JSON round-trip Co-authored-by: c1-squire-dev[bot] --- pkg/models/shared/marshal_roundtrip_test.go | 289 ++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 pkg/models/shared/marshal_roundtrip_test.go diff --git a/pkg/models/shared/marshal_roundtrip_test.go b/pkg/models/shared/marshal_roundtrip_test.go new file mode 100644 index 000000000..cf70bfe2d --- /dev/null +++ b/pkg/models/shared/marshal_roundtrip_test.go @@ -0,0 +1,289 @@ +package shared + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMarshalRoundTrip_IntegerStringTags verifies that types with integer:"string" +// tags correctly round-trip through JSON marshal/unmarshal. +// This is a regression test for https://linear.app/conductorone/issue/IGA-719 +func TestMarshalRoundTrip_IntegerStringTags(t *testing.T) { + tests := []struct { + name string + json string + target interface{ MarshalJSON() ([]byte, error) } + validate func(t *testing.T, marshaled string) + }{ + { + name: "FacetValue with count as string", + json: `{"count":"42","displayName":"test","value":"val1"}`, + target: &FacetValue{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"42"`, string(m["count"]), "count should be serialized as a string") + }, + }, + { + name: "FacetRange with integer string fields", + json: `{"count":"10","from":"1","to":"100"}`, + target: &FacetRange{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"10"`, string(m["count"])) + assert.Equal(t, `"1"`, string(m["from"])) + assert.Equal(t, `"100"`, string(m["to"])) + }, + }, + { + name: "Facets with count", + json: `{"count":"5","displayName":"category","fieldName":"type"}`, + target: &Facets{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"5"`, string(m["count"])) + }, + }, + { + name: "AutomationExecutionRef with id", + json: `{"id":"12345"}`, + target: &AutomationExecutionRef{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"12345"`, string(m["id"])) + }, + }, + { + name: "Int64Field with defaultValue", + json: `{"defaultValue":"999"}`, + target: &Int64Field{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"999"`, string(m["defaultValue"])) + }, + }, + { + name: "Escalation with expiration", + json: `{"expiration":"3600","escalationComment":"urgent"}`, + target: &Escalation{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"3600"`, string(m["expiration"])) + }, + }, + { + name: "Task with numericId", + json: `{"id":"task-1","numericId":"456","state":"TASK_STATE_OPEN"}`, + target: &Task{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"456"`, string(m["numericId"])) + }, + }, + { + name: "NumberField with integer string fields", + json: `{"maxValue":"100","minValue":"0","step":"1"}`, + target: &NumberField{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"100"`, string(m["maxValue"])) + assert.Equal(t, `"0"`, string(m["minValue"])) + assert.Equal(t, `"1"`, string(m["step"])) + }, + }, + { + name: "AppResourceInput with grantCount", + json: `{"grantCount":"7"}`, + target: &AppResourceInput{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"7"`, string(m["grantCount"])) + }, + }, + { + name: "ExecuteAutomationResponse with executionId", + json: `{"executionId":"123"}`, + target: &ExecuteAutomationResponse{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"123"`, string(m["executionId"])) + }, + }, + { + name: "FileField with maxFileSize", + json: `{"maxFileSize":"1048576"}`, + target: &FileField{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"1048576"`, string(m["maxFileSize"])) + }, + }, + { + name: "Int64Rules with integer string fields", + json: `{"const":"5","gt":"1","gte":"2","in":["3","4"],"lt":"10","lte":"9","notIn":["6","7"]}`, + target: &Int64Rules{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"5"`, string(m["const"])) + assert.Equal(t, `"1"`, string(m["gt"])) + assert.Equal(t, `"2"`, string(m["gte"])) + assert.Equal(t, `["3","4"]`, string(m["in"])) + assert.Equal(t, `"10"`, string(m["lt"])) + assert.Equal(t, `"9"`, string(m["lte"])) + assert.Equal(t, `["6","7"]`, string(m["notIn"])) + }, + }, + { + name: "PayloadWorkflowStep with workflowExecutionId", + json: `{"workflowExecutionId":"456"}`, + target: &PayloadWorkflowStep{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"456"`, string(m["workflowExecutionId"])) + }, + }, + { + name: "RequestCatalogView with memberCount", + json: `{"memberCount":"12"}`, + target: &RequestCatalogView{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"12"`, string(m["memberCount"])) + }, + }, + { + name: "SearchAutomationExecutionsRequest with executionId", + json: `{"executionId":"789"}`, + target: &SearchAutomationExecutionsRequest{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"789"`, string(m["executionId"])) + }, + }, + { + name: "SFixed64Rules with integer string fields", + json: `{"const":"5","gt":"1","gte":"2","in":["3","4"],"lt":"10","lte":"9","notIn":["6","7"]}`, + target: &SFixed64Rules{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"5"`, string(m["const"])) + assert.Equal(t, `["3","4"]`, string(m["in"])) + }, + }, + { + name: "SInt64Rules with integer string fields", + json: `{"const":"5","gt":"1","gte":"2","in":["3","4"],"lt":"10","lte":"9","notIn":["6","7"]}`, + target: &SInt64Rules{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"5"`, string(m["const"])) + assert.Equal(t, `["3","4"]`, string(m["in"])) + }, + }, + { + name: "TaskAuditErrorResult with errorCount", + json: `{"errorCount":"3"}`, + target: &TaskAuditErrorResult{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"3"`, string(m["errorCount"])) + }, + }, + { + name: "WebhookSourceWorkflowStep with workflowExecutionId", + json: `{"workflowExecutionId":"321"}`, + target: &WebhookSourceWorkflowStep{}, + validate: func(t *testing.T, marshaled string) { + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) + assert.Equal(t, `"321"`, string(m["workflowExecutionId"])) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Unmarshal from JSON (simulating API response) + err := json.Unmarshal([]byte(tt.json), tt.target) + require.NoError(t, err, "unmarshal should succeed") + + // Marshal back to JSON (the operation reported in IGA-719) + data, err := json.Marshal(tt.target) + require.NoError(t, err, "marshal should succeed") + + // Validate the output + tt.validate(t, string(data)) + + // Verify full round-trip: unmarshal the marshaled data again + target2 := tt.target + err = json.Unmarshal(data, target2) + require.NoError(t, err, "second unmarshal should succeed") + + data2, err := json.Marshal(target2) + require.NoError(t, err, "second marshal should succeed") + assert.JSONEq(t, string(data), string(data2), "round-trip should produce identical JSON") + }) + } +} + +// TestMarshalRoundTrip_UserServiceListResponse tests the exact scenario +// from the issue: List Users -> Marshal back to JSON. +func TestMarshalRoundTrip_UserServiceListResponse(t *testing.T) { + input := `{ + "list": [ + { + "user": { + "id": "user-123", + "email": "test@example.com", + "displayName": "Test User", + "status": "ENABLED", + "directoryStatus": "ENABLED", + "department": "Engineering" + } + } + ], + "nextPageToken": "token123" + }` + + var resp UserServiceListResponse + err := json.Unmarshal([]byte(input), &resp) + require.NoError(t, err) + + data, err := json.Marshal(resp) + require.NoError(t, err) + + // Verify key fields are preserved + var check map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &check)) + assert.Contains(t, string(check["list"]), `"user-123"`) + assert.Contains(t, string(check["list"]), `"test@example.com"`) + assert.Equal(t, `"token123"`, string(check["nextPageToken"])) + + // Verify second round-trip + var resp2 UserServiceListResponse + require.NoError(t, json.Unmarshal(data, &resp2)) + data2, err := json.Marshal(resp2) + require.NoError(t, err) + assert.JSONEq(t, string(data), string(data2)) +} From 7dfceb1022b5619cca9a99c81537ffec0c290d51 Mon Sep 17 00:00:00 2001 From: Bjorn Tipling Date: Mon, 24 Aug 2026 23:29:57 +0000 Subject: [PATCH 2/2] Strengthen integer:"string" round-trip regression tests - Add lossless-vs-input assertion and fresh-target round-trip (FN4) - Add integer:"string" list-response test (FN1) - Add negative/error-path unmarshal tests (FN3) - Replace fragile raw-JSON Contains with structured assertions (FN5) - Assert all fields in SFixed64Rules/SInt64Rules cases (FN6) - Correct Facets test input to only real fields Co-authored-by: c1-squire-dev[bot] --- pkg/models/shared/marshal_roundtrip_test.go | 106 +++++++++++++++++--- 1 file changed, 94 insertions(+), 12 deletions(-) diff --git a/pkg/models/shared/marshal_roundtrip_test.go b/pkg/models/shared/marshal_roundtrip_test.go index cf70bfe2d..bc0b33c0f 100644 --- a/pkg/models/shared/marshal_roundtrip_test.go +++ b/pkg/models/shared/marshal_roundtrip_test.go @@ -2,6 +2,7 @@ package shared import ( "encoding/json" + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -42,7 +43,7 @@ func TestMarshalRoundTrip_IntegerStringTags(t *testing.T) { }, { name: "Facets with count", - json: `{"count":"5","displayName":"category","fieldName":"type"}`, + json: `{"count":"5"}`, target: &Facets{}, validate: func(t *testing.T, marshaled string) { var m map[string]json.RawMessage @@ -186,7 +187,12 @@ func TestMarshalRoundTrip_IntegerStringTags(t *testing.T) { var m map[string]json.RawMessage require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) assert.Equal(t, `"5"`, string(m["const"])) + assert.Equal(t, `"1"`, string(m["gt"])) + assert.Equal(t, `"2"`, string(m["gte"])) assert.Equal(t, `["3","4"]`, string(m["in"])) + assert.Equal(t, `"10"`, string(m["lt"])) + assert.Equal(t, `"9"`, string(m["lte"])) + assert.Equal(t, `["6","7"]`, string(m["notIn"])) }, }, { @@ -197,7 +203,12 @@ func TestMarshalRoundTrip_IntegerStringTags(t *testing.T) { var m map[string]json.RawMessage require.NoError(t, json.Unmarshal([]byte(marshaled), &m)) assert.Equal(t, `"5"`, string(m["const"])) + assert.Equal(t, `"1"`, string(m["gt"])) + assert.Equal(t, `"2"`, string(m["gte"])) assert.Equal(t, `["3","4"]`, string(m["in"])) + assert.Equal(t, `"10"`, string(m["lt"])) + assert.Equal(t, `"9"`, string(m["lte"])) + assert.Equal(t, `["6","7"]`, string(m["notIn"])) }, }, { @@ -232,11 +243,18 @@ func TestMarshalRoundTrip_IntegerStringTags(t *testing.T) { data, err := json.Marshal(tt.target) require.NoError(t, err, "marshal should succeed") + // Lossless vs the original input: every table input is fully + // round-trippable (all fields are real, non-omitempty-dropped fields + // of the target type), so the marshaled output must equal the input. + assert.JSONEq(t, tt.json, string(data), "marshal should preserve the input") + // Validate the output tt.validate(t, string(data)) - // Verify full round-trip: unmarshal the marshaled data again - target2 := tt.target + // Verify full round-trip: unmarshal the marshaled data into a FRESH + // instance (not the already-populated target) so a marshal-drop bug + // cannot be masked by stale field values. + target2 := reflect.New(reflect.TypeOf(tt.target).Elem()).Interface().(interface{ MarshalJSON() ([]byte, error) }) err = json.Unmarshal(data, target2) require.NoError(t, err, "second unmarshal should succeed") @@ -273,17 +291,81 @@ func TestMarshalRoundTrip_UserServiceListResponse(t *testing.T) { data, err := json.Marshal(resp) require.NoError(t, err) - // Verify key fields are preserved - var check map[string]json.RawMessage - require.NoError(t, json.Unmarshal(data, &check)) - assert.Contains(t, string(check["list"]), `"user-123"`) - assert.Contains(t, string(check["list"]), `"test@example.com"`) - assert.Equal(t, `"token123"`, string(check["nextPageToken"])) - - // Verify second round-trip + // Verify key fields are preserved via structured assertions on a fresh + // decode of the marshaled output (not substring matching on raw JSON). var resp2 UserServiceListResponse require.NoError(t, json.Unmarshal(data, &resp2)) - data2, err := json.Marshal(resp2) + require.Len(t, resp2.List, 1) + require.NotNil(t, resp2.List[0].User) + require.NotNil(t, resp2.List[0].User.GetID()) + assert.Equal(t, "user-123", *resp2.List[0].User.GetID()) + require.NotNil(t, resp2.List[0].User.GetEmail()) + assert.Equal(t, "test@example.com", *resp2.List[0].User.GetEmail()) + require.NotNil(t, resp2.GetNextPageToken()) + assert.Equal(t, "token123", *resp2.GetNextPageToken()) + + // Verify second round-trip + var resp3 UserServiceListResponse + require.NoError(t, json.Unmarshal(data, &resp3)) + data2, err := json.Marshal(resp3) require.NoError(t, err) assert.JSONEq(t, string(data), string(data2)) } + +// TestMarshalRoundTrip_IntegerStringListResponse exercises the integer:"string" +// path in a realistic list-response scenario. The literal "List Users" types +// (User/UserView/UserServiceListResponse) carry no integer:"string" fields, so +// this response type (with GraphAppGrantCount.GrantCount tagged integer:"string") +// is used to cover the IGA-719 marshal-back path in a list response. +func TestMarshalRoundTrip_IntegerStringListResponse(t *testing.T) { + input := `{ + "appCount": 2, + "appGrantCounts": [ + {"appId": "app-1", "grantCount": "7"}, + {"appId": "app-2", "grantCount": "3"} + ] + }` + + var resp AppEntitlementSearchServiceCountGrantsForUserByAppResponse + err := json.Unmarshal([]byte(input), &resp) + require.NoError(t, err) + + data, err := json.Marshal(resp) + require.NoError(t, err) + + // grantCount must be serialized as a string, not a number. + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &m)) + assert.Contains(t, string(m["appGrantCounts"]), `"grantCount":"7"`) + assert.Contains(t, string(m["appGrantCounts"]), `"grantCount":"3"`) + + // Lossless round-trip vs the original input. + assert.JSONEq(t, input, string(data)) +} + +// TestUnmarshalIntegerString_RejectsBadInput verifies the error paths of the +// integer:"string" unmarshal branch: a non-string value and an invalid string +// must both be rejected with an error. +func TestUnmarshalIntegerString_RejectsBadInput(t *testing.T) { + tests := []struct { + name string + json string + }{ + { + name: "non-string number input", + json: `{"count":42}`, + }, + { + name: "invalid string input", + json: `{"count":"abc"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f FacetValue + err := json.Unmarshal([]byte(tt.json), &f) + require.Error(t, err, "unmarshal of bad integer:\"string\" input should error") + }) + } +}