diff --git a/pkg/models/shared/marshal_roundtrip_test.go b/pkg/models/shared/marshal_roundtrip_test.go new file mode 100644 index 000000000..bc0b33c0f --- /dev/null +++ b/pkg/models/shared/marshal_roundtrip_test.go @@ -0,0 +1,371 @@ +package shared + +import ( + "encoding/json" + "reflect" + "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"}`, + 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, `"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: "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, `"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: "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") + + // 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 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") + + 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 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)) + 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") + }) + } +}