From e21ffc99671a0c8c3c06c178cee5c0ec81027e19 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:50:25 +0000 Subject: [PATCH] fix: handle integer:"string" tags in map values during JSON unmarshal The unmarshalValue function's map handling fell through to standard json.Unmarshal for maps with struct value types. Standard json doesn't understand the integer:"string" struct tag, so nested structs like Escalation with string-encoded int64 fields (e.g., expiration: "3600") failed to unmarshal. This fix extends the recursive unmarshal path (already used for complex types like time.Time) to also handle model types (structs), ensuring custom struct tags are respected throughout the entire unmarshal chain. Fixes IGA-731 --- pkg/utils/json.go | 10 +++- pkg/utils/json_test.go | 115 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 pkg/utils/json_test.go diff --git a/pkg/utils/json.go b/pkg/utils/json.go index addfe8d78..db1de6cf3 100644 --- a/pkg/utils/json.go +++ b/pkg/utils/json.go @@ -499,7 +499,8 @@ func unmarshalValue(value json.RawMessage, v reflect.Value, tag reflect.StructTa return nil } case reflect.Map: - if bytes.Equal(value, []byte("null")) || !isComplexValueType(dereferenceTypePointer(typ.Elem())) { + elemType := dereferenceTypePointer(typ.Elem()) + if bytes.Equal(value, []byte("null")) || (!isComplexValueType(elemType) && !isModelType(elemType)) { if v.CanAddr() { return json.Unmarshal(value, v.Addr().Interface()) } else { @@ -525,6 +526,13 @@ func unmarshalValue(value json.RawMessage, v reflect.Value, tag reflect.StructTa m.SetMapIndex(reflect.ValueOf(k), itemVal.Elem()) } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(typ)) + } + v = v.Elem() + } + v.Set(m) return nil case reflect.Slice, reflect.Array: diff --git a/pkg/utils/json_test.go b/pkg/utils/json_test.go new file mode 100644 index 000000000..b902c1a90 --- /dev/null +++ b/pkg/utils/json_test.go @@ -0,0 +1,115 @@ +package utils + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestUnmarshalMapWithNestedStringInt64 reproduces the issue where a map[string]Struct +// containing nested structs with integer:"string" tags fails to unmarshal because the +// map handling falls through to standard json.Unmarshal which doesn't understand the tag. +// This is the root cause of the Escalation.Expiration unmarshal failure (IGA-731). +func TestUnmarshalMapWithNestedStringInt64(t *testing.T) { + // Inner struct with integer:"string" tag (like Escalation) + type Escalation struct { + Expiration *int64 `integer:"string" json:"expiration,omitempty"` + } + + // Intermediate struct (like Approval) - no custom UnmarshalJSON + type Approval struct { + Escalation *Escalation `json:"escalation,omitempty"` + } + + // Step struct (like PolicyStep) + type PolicyStep struct { + Approval *Approval `json:"approval,omitempty"` + } + + // Steps container (like PolicySteps) - used as map value + type PolicySteps struct { + Steps []PolicyStep `json:"steps,omitempty"` + } + + // Top-level struct (like Policy) + type Policy struct { + PolicySteps map[string]PolicySteps `json:"policySteps,omitempty"` + } + + input := `{ + "policySteps": { + "grant": { + "steps": [{ + "approval": { + "escalation": { + "expiration": "3600" + } + } + }] + } + } + }` + + var policy Policy + err := UnmarshalJSON([]byte(input), &policy, "", false, nil) + require.NoError(t, err, "should unmarshal string-encoded int64 in map values") + + require.NotNil(t, policy.PolicySteps) + steps, ok := policy.PolicySteps["grant"] + require.True(t, ok) + require.Len(t, steps.Steps, 1) + require.NotNil(t, steps.Steps[0].Approval) + require.NotNil(t, steps.Steps[0].Approval.Escalation) + require.NotNil(t, steps.Steps[0].Approval.Escalation.Expiration) + assert.Equal(t, int64(3600), *steps.Steps[0].Approval.Escalation.Expiration) +} + +// TestUnmarshalMapWithNestedStringInt64_NumericValue ensures the fix also handles +// the case where the API returns a numeric value instead of a string. +func TestUnmarshalMapWithNestedStringInt64_NumericValue(t *testing.T) { + type Inner struct { + Value *int64 `integer:"string" json:"value,omitempty"` + } + type Outer struct { + Items map[string]Inner `json:"items,omitempty"` + } + + // When the API returns a string-encoded integer + input := `{"items": {"a": {"value": "42"}}}` + var out Outer + err := UnmarshalJSON([]byte(input), &out, "", false, nil) + require.NoError(t, err) + require.NotNil(t, out.Items["a"].Value) + assert.Equal(t, int64(42), *out.Items["a"].Value) +} + +// TestMarshalMapWithNestedStringInt64 ensures marshaling also works correctly. +func TestMarshalMapWithNestedStringInt64(t *testing.T) { + type Inner struct { + Value *int64 `integer:"string" json:"value,omitempty"` + } + type Outer struct { + Items map[string]Inner `json:"items,omitempty"` + } + + val := int64(42) + out := Outer{ + Items: map[string]Inner{ + "a": {Value: &val}, + }, + } + + data, err := MarshalJSON(out, "", false) + require.NoError(t, err) + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &raw)) + + var items map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw["items"], &items)) + + // The value should be marshaled as a string due to integer:"string" tag + assert.Equal(t, `{"value":"42"}`, string(items["a"])) +}