Skip to content
Merged
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
371 changes: 371 additions & 0 deletions pkg/models/shared/marshal_roundtrip_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
Loading