Files
mattermost/server/public/model/custom_profile_attributes_test.go
T

1052 lines
30 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCPAFieldFromPropertyField(t *testing.T) {
testCases := []struct {
name string
propertyField *PropertyField
wantAttrs CPAAttrs
wantErr bool
}{
{
name: "valid property field with all attributes",
propertyField: &PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityAlways,
CustomProfileAttributesPropertyAttrsSortOrder: 1,
CustomProfileAttributesPropertyAttrsValueType: CustomProfileAttributesValueTypeEmail,
PropertyFieldAttributeOptions: []*CustomProfileAttributesSelectOption{
{
ID: NewId(),
Name: "Option 1",
Color: "#FF0000",
},
},
},
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
wantAttrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 1,
ValueType: CustomProfileAttributesValueTypeEmail,
Options: []*CustomProfileAttributesSelectOption{
{
ID: "", // ID will be different in each test run
Name: "Option 1",
Color: "#FF0000",
},
},
},
wantErr: false,
},
{
name: "valid property field with minimal attributes",
propertyField: &PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeText,
Attrs: StringInterface{
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityWhenSet,
CustomProfileAttributesPropertyAttrsSortOrder: 2,
},
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
wantAttrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 2,
ValueType: "",
Options: nil,
},
wantErr: false,
},
{
// Conversion is a pure data operation: empty PropertyField.Attrs
// produces empty CPAAttrs. The visibility default is applied at
// write time by AccessControlAttributeValidationHook, not at read time.
name: "property field with empty attributes returns empty CPAAttrs",
propertyField: &PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Empty Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
wantAttrs: CPAAttrs{},
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cpaField, err := NewCPAFieldFromPropertyField(tc.propertyField)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, cpaField)
// Check that the PropertyField was copied correctly
assert.Equal(t, tc.propertyField.ID, cpaField.ID)
assert.Equal(t, tc.propertyField.GroupID, cpaField.GroupID)
assert.Equal(t, tc.propertyField.Name, cpaField.Name)
assert.Equal(t, tc.propertyField.Type, cpaField.Type)
// Check that the attributes were parsed correctly
assert.Equal(t, tc.wantAttrs.Visibility, cpaField.Attrs.Visibility)
assert.Equal(t, tc.wantAttrs.SortOrder, cpaField.Attrs.SortOrder)
assert.Equal(t, tc.wantAttrs.ValueType, cpaField.Attrs.ValueType)
// For options, we need to check length since IDs will be different
if tc.wantAttrs.Options != nil {
require.NotNil(t, cpaField.Attrs.Options)
assert.Len(t, cpaField.Attrs.Options, len(tc.wantAttrs.Options))
if len(tc.wantAttrs.Options) > 0 {
assert.Equal(t, tc.wantAttrs.Options[0].Name, cpaField.Attrs.Options[0].Name)
assert.Equal(t, tc.wantAttrs.Options[0].Color, cpaField.Attrs.Options[0].Color)
}
} else {
assert.Nil(t, cpaField.Attrs.Options)
}
})
}
}
func TestCPAFieldToPropertyField(t *testing.T) {
tests := []struct {
name string
cpaField *CPAField
}{
{
name: "convert CPA field with all attributes",
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeSelect,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 1,
ValueType: CustomProfileAttributesValueTypeEmail,
Options: []*CustomProfileAttributesSelectOption{
{
ID: NewId(),
Name: "Option 1",
Color: "#FF0000",
},
},
},
},
},
{
name: "convert CPA field with minimal attributes",
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 2,
},
},
},
{
name: "convert CPA field with empty attributes",
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Empty Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
Attrs: CPAAttrs{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pf := tt.cpaField.ToPropertyField()
require.NotNil(t, pf)
// Check that the PropertyField was copied correctly
assert.Equal(t, tt.cpaField.ID, pf.ID)
assert.Equal(t, tt.cpaField.GroupID, pf.GroupID)
assert.Equal(t, tt.cpaField.Name, pf.Name)
assert.Equal(t, tt.cpaField.Type, pf.Type)
// Check that the attributes were converted correctly
assert.Equal(t, tt.cpaField.Attrs.Visibility, pf.Attrs[CustomProfileAttributesPropertyAttrsVisibility])
assert.Equal(t, tt.cpaField.Attrs.SortOrder, pf.Attrs[CustomProfileAttributesPropertyAttrsSortOrder])
assert.Equal(t, tt.cpaField.Attrs.ValueType, pf.Attrs[CustomProfileAttributesPropertyAttrsValueType])
// Check options
options, ok := pf.Attrs[PropertyFieldAttributeOptions]
if tt.cpaField.Attrs.Options != nil {
require.True(t, ok)
optionsSlice, ok := options.(PropertyOptions[*CustomProfileAttributesSelectOption])
require.True(t, ok)
assert.Len(t, optionsSlice, len(tt.cpaField.Attrs.Options))
}
})
}
// Test managed attribute functionality
t.Run("managed attribute", func(t *testing.T) {
managedTests := []struct {
name string
cpaField *CPAField
}{
{
name: "CPA field with managed attribute should include it in conversion",
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Managed Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 1,
Managed: "admin",
},
},
},
{
name: "CPA field with empty managed attribute should include it in conversion",
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "Non-managed Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 2,
Managed: "",
},
},
},
}
for _, tt := range managedTests {
t.Run(tt.name, func(t *testing.T) {
pf := tt.cpaField.ToPropertyField()
require.NotNil(t, pf)
// Check that the PropertyField was copied correctly
assert.Equal(t, tt.cpaField.ID, pf.ID)
assert.Equal(t, tt.cpaField.GroupID, pf.GroupID)
assert.Equal(t, tt.cpaField.Name, pf.Name)
assert.Equal(t, tt.cpaField.Type, pf.Type)
// Check that the managed attribute was converted correctly
assert.Equal(t, tt.cpaField.Attrs.Managed, pf.Attrs[CustomProfileAttributesPropertyAttrsManaged])
})
}
})
}
func TestCustomProfileAttributeSelectOptionIsValid(t *testing.T) {
tests := []struct {
name string
option CustomProfileAttributesSelectOption
wantErr string
}{
{
name: "valid option with color",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: "Test Option",
Color: "#FF0000",
},
wantErr: "",
},
{
name: "valid option without color",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: "Test Option",
},
wantErr: "",
},
{
name: "empty ID",
option: CustomProfileAttributesSelectOption{
ID: "",
Name: "Test Option",
Color: "#FF0000",
},
wantErr: "id cannot be empty",
},
{
name: "invalid ID",
option: CustomProfileAttributesSelectOption{
ID: "invalid-id",
Name: "Test Option",
Color: "#FF0000",
},
wantErr: "id is not a valid ID",
},
{
name: "empty name",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: "",
Color: "#FF0000",
},
wantErr: "name cannot be empty",
},
{
name: "name too long",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: strings.Repeat("a", CPAOptionNameMaxLength+1),
Color: "#FF0000",
},
wantErr: fmt.Sprintf("name is too long, max length is %d", CPAOptionNameMaxLength),
},
{
name: "color too long",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: "Test Option",
Color: strings.Repeat("a", CPAOptionColorMaxLength+1),
},
wantErr: fmt.Sprintf("color is too long, max length is %d", CPAOptionColorMaxLength),
},
{
name: "name exactly at max length",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: strings.Repeat("a", CPAOptionNameMaxLength),
Color: "#FF0000",
},
wantErr: "",
},
{
name: "color exactly at max length",
option: CustomProfileAttributesSelectOption{
ID: NewId(),
Name: "Test Option",
Color: strings.Repeat("a", CPAOptionColorMaxLength),
},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.option.IsValid()
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
})
}
}
// TestCPAField_SanitizeAndValidate removed: behavior moved into AccessControlAttributeValidationHook;
// see TestAccessControlAttributeValidationHook in server/channels/app/properties/access_control_attribute_validation_test.go.
func TestValidateCPAFieldName(t *testing.T) {
tests := []struct {
name string
input string
wantErrID string // empty means expect nil (valid)
}{
// Accept
{name: "simple lowercase", input: "department", wantErrID: ""},
{name: "leading underscore", input: "_private", wantErrID: ""},
{name: "uppercase start", input: "Department", wantErrID: ""},
{name: "single uppercase", input: "A1", wantErrID: ""},
{name: "underscore separator", input: "a_b_c", wantErrID: ""},
{name: "all uppercase", input: "DEPT", wantErrID: ""},
// Case sensitivity of reserved-word lookup
{name: "case-sensitive: IN is not reserved", input: "IN", wantErrID: ""},
{name: "case-sensitive: In is not reserved", input: "In", wantErrID: ""},
// Single-character valid names
{name: "single lowercase letter", input: "a", wantErrID: ""},
{name: "single underscore", input: "_", wantErrID: ""},
{name: "single uppercase letter", input: "A", wantErrID: ""},
// Reject — charset
{name: "space in name", input: "My Field", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
{name: "leading digit", input: "7department", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
{name: "hyphen", input: "foo-bar", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
{name: "emoji", input: "🎯", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
{name: "empty string", input: "", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
{name: "trailing space", input: "name ", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
{name: "non-ASCII letter", input: "départment", wantErrID: "model.cpa_field.name.invalid_charset.app_error"},
// Reject — reserved words
{name: "reserved: in", input: "in", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: as", input: "as", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: true", input: "true", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: false", input: "false", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: null", input: "null", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: function", input: "function", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: var", input: "var", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: return", input: "return", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: if", input: "if", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: for", input: "for", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
{name: "reserved: import", input: "import", wantErrID: "model.cpa_field.name.reserved_word.app_error"},
// Boundary — reserved-word prefix/suffix not reserved (e.g. "trueish")
{name: "reserved word as prefix", input: "trueish", wantErrID: ""},
{name: "reserved word as suffix", input: "my_null", wantErrID: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
appErr := ValidateCPAFieldName(tt.input)
if tt.wantErrID == "" {
require.Nil(t, appErr, "expected nil for input %q, got %v", tt.input, appErr)
} else {
require.NotNil(t, appErr, "expected error for input %q", tt.input)
require.Equal(t, tt.wantErrID, appErr.Id)
}
})
}
}
func TestCPAField_ToPropertyField_DisplayName(t *testing.T) {
t.Run("DisplayName round-trips through ToPropertyField and NewCPAFieldFromPropertyField", func(t *testing.T) {
original := &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "department",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 3.0,
DisplayName: "Department",
},
}
pf := original.ToPropertyField()
require.NotNil(t, pf)
require.Equal(t, "Department", pf.Attrs[CustomProfileAttributesPropertyAttrsDisplayName],
"DisplayName must be written into attrs StringInterface by ToPropertyField")
roundTripped, err := NewCPAFieldFromPropertyField(pf)
require.NoError(t, err)
require.Equal(t, "Department", roundTripped.Attrs.DisplayName,
"DisplayName must survive the ToPropertyField → NewCPAFieldFromPropertyField round-trip")
})
t.Run("empty DisplayName round-trips as empty string", func(t *testing.T) {
field := &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "department",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
}
pf := field.ToPropertyField()
// With omitempty, an empty DisplayName should still be written (as empty string) to
// the StringInterface; NewCPAFieldFromPropertyField should unmarshal it as "".
roundTripped, err := NewCPAFieldFromPropertyField(pf)
require.NoError(t, err)
require.Equal(t, "", roundTripped.Attrs.DisplayName)
})
}
func TestCPAField_IsAdminManaged(t *testing.T) {
tests := []struct {
name string
field *CPAField
expected bool
}{
{
name: "field with managed admin attribute should return true",
field: &CPAField{
Attrs: CPAAttrs{
Managed: "admin",
},
},
expected: true,
},
{
name: "field with empty managed attribute should return false",
field: &CPAField{
Attrs: CPAAttrs{
Managed: "",
},
},
expected: false,
},
{
name: "field with non-admin managed attribute should return false",
field: &CPAField{
Attrs: CPAAttrs{
Managed: "user",
},
},
expected: false,
},
{
name: "field with no managed attribute should return false",
field: &CPAField{
Attrs: CPAAttrs{},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.field.IsAdminManaged()
assert.Equal(t, tt.expected, result)
})
}
}
// TestCPAField_SetDefaults removed: visibility default is now applied by AccessControlAttributeValidationHook
// (see access_control_attribute_validation.go), exercised in TestAccessControlAttributeValidationHook.
func TestCPAField_Patch(t *testing.T) {
testCases := []struct {
name string
field *CPAField
patch *PropertyFieldPatch
expectedField *CPAField
expectError bool
}{
{
name: "patch name",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Original Name",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
patch: &PropertyFieldPatch{
Name: new("Updated Name"),
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Updated Name",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
expectError: false,
},
{
name: "patch type",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
patch: &PropertyFieldPatch{
Type: new(PropertyFieldTypeSelect),
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeSelect,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
expectError: false,
},
{
name: "patch visibility (attrs replacement - sort order is lost)",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 1.0,
},
},
patch: &PropertyFieldPatch{
Attrs: &StringInterface{
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityAlways,
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 0, // Lost because attrs is replaced, not merged
},
},
expectError: false,
},
{
name: "patch visibility preserving sort order (must include both in patch)",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 1.0,
},
},
patch: &PropertyFieldPatch{
Attrs: &StringInterface{
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityAlways,
CustomProfileAttributesPropertyAttrsSortOrder: 1.0, // Must include to preserve
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 1.0,
},
},
expectError: false,
},
{
// Patch with non-nil Attrs replaces the whole Attrs map; visibility
// drops to "" because the patch doesn't include it. The visibility
// default is reapplied at write time by AccessControlAttributeValidationHook,
// not by Patch itself.
name: "patch sort order",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 1.0,
},
},
patch: &PropertyFieldPatch{
Attrs: &StringInterface{
CustomProfileAttributesPropertyAttrsSortOrder: 10.5,
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
SortOrder: 10.5,
},
},
expectError: false,
},
{
name: "patch managed attribute",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
Managed: "",
},
},
patch: &PropertyFieldPatch{
Attrs: &StringInterface{
CustomProfileAttributesPropertyAttrsManaged: "admin",
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Managed: "admin",
},
},
expectError: false,
},
{
name: "patch LDAP attribute",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
patch: &PropertyFieldPatch{
Attrs: &StringInterface{
CustomProfileAttributesPropertyAttrsLDAP: "ldap_attribute",
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
LDAP: "ldap_attribute",
},
},
expectError: false,
},
{
name: "patch options for select field",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeSelect,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
Options: []*CustomProfileAttributesSelectOption{
{ID: "opt1", Name: "Option 1"},
},
},
},
patch: &PropertyFieldPatch{
Attrs: &StringInterface{
PropertyFieldAttributeOptions: []*CustomProfileAttributesSelectOption{
{ID: "opt1", Name: "Option 1"},
{ID: "opt2", Name: "Option 2"},
},
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Test Field",
Type: PropertyFieldTypeSelect,
},
Attrs: CPAAttrs{
Options: []*CustomProfileAttributesSelectOption{
{ID: "opt1", Name: "Option 1"},
{ID: "opt2", Name: "Option 2"},
},
},
},
expectError: false,
},
{
name: "patch with TargetID should clear it (CPA doesn't use targets)",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
patch: &PropertyFieldPatch{
Name: new("Updated Name"),
TargetID: new("should-be-cleared"),
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Updated Name",
Type: PropertyFieldTypeText,
TargetID: "", // Should be empty
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
expectError: false,
},
{
name: "patch with TargetType should clear it (CPA doesn't use targets)",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
patch: &PropertyFieldPatch{
Name: new("Updated Name"),
TargetType: new("should-be-cleared"),
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "Updated Name",
Type: PropertyFieldTypeText,
TargetType: "", // Should be empty
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
},
},
expectError: false,
},
{
name: "patch multiple attributes at once",
field: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
GroupID: "group1",
Name: "Original Name",
Type: PropertyFieldTypeText,
CreateAt: 1000,
UpdateAt: 1000,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityWhenSet,
SortOrder: 1.0,
Managed: "",
},
},
patch: &PropertyFieldPatch{
Name: new("New Name"),
Attrs: &StringInterface{
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityAlways,
CustomProfileAttributesPropertyAttrsSortOrder: 5.0,
CustomProfileAttributesPropertyAttrsManaged: "admin",
},
},
expectedField: &CPAField{
PropertyField: PropertyField{
Name: "New Name",
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
Visibility: CustomProfileAttributesVisibilityAlways,
SortOrder: 5.0,
Managed: "admin",
},
},
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.field.Patch(tc.patch)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Check PropertyField attributes
assert.Equal(t, tc.expectedField.Name, tc.field.Name)
assert.Equal(t, tc.expectedField.Type, tc.field.Type)
assert.Equal(t, tc.expectedField.TargetID, tc.field.TargetID)
assert.Equal(t, tc.expectedField.TargetType, tc.field.TargetType)
// Check CPAAttrs
assert.Equal(t, tc.expectedField.Attrs.Visibility, tc.field.Attrs.Visibility)
assert.Equal(t, tc.expectedField.Attrs.SortOrder, tc.field.Attrs.SortOrder)
assert.Equal(t, tc.expectedField.Attrs.Managed, tc.field.Attrs.Managed)
assert.Equal(t, tc.expectedField.Attrs.LDAP, tc.field.Attrs.LDAP)
assert.Equal(t, tc.expectedField.Attrs.SAML, tc.field.Attrs.SAML)
// Check options if present
if tc.expectedField.Attrs.Options != nil {
require.Len(t, tc.field.Attrs.Options, len(tc.expectedField.Attrs.Options))
for i, expectedOpt := range tc.expectedField.Attrs.Options {
assert.Equal(t, expectedOpt.ID, tc.field.Attrs.Options[i].ID)
assert.Equal(t, expectedOpt.Name, tc.field.Attrs.Options[i].Name)
}
} else {
assert.Nil(t, tc.field.Attrs.Options)
}
})
}
}
func TestCPAFieldsFromPropertyFields(t *testing.T) {
mkField := func(name string, sortOrder float64) *PropertyField {
return &PropertyField{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: name,
Type: PropertyFieldTypeText,
Attrs: StringInterface{
CustomProfileAttributesPropertyAttrsSortOrder: sortOrder,
},
}
}
t.Run("empty slice returns empty slice", func(t *testing.T) {
result, err := CPAFieldsFromPropertyFields(nil)
require.NoError(t, err)
assert.Empty(t, result)
})
t.Run("sorts by SortOrder ascending", func(t *testing.T) {
input := []*PropertyField{
mkField("c", 2),
mkField("a", 0),
mkField("b", 1),
}
result, err := CPAFieldsFromPropertyFields(input)
require.NoError(t, err)
require.Len(t, result, 3)
assert.Equal(t, "a", result[0].Name)
assert.Equal(t, "b", result[1].Name)
assert.Equal(t, "c", result[2].Name)
})
t.Run("preserves fields with equal SortOrder in encounter order", func(t *testing.T) {
input := []*PropertyField{
mkField("first", 0),
mkField("second", 0),
}
result, err := CPAFieldsFromPropertyFields(input)
require.NoError(t, err)
require.Len(t, result, 2)
// sort.Slice is not stable, but the test asserts both possible stable outcomes
// — we care that both fields are present, not stability.
names := []string{result[0].Name, result[1].Name}
assert.Contains(t, names, "first")
assert.Contains(t, names, "second")
})
t.Run("propagates conversion errors", func(t *testing.T) {
// options stored as an invalid JSON-marshallable type so that
// json.Marshal fails inside NewCPAFieldFromPropertyField
input := []*PropertyField{{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "bad",
Type: PropertyFieldTypeText,
Attrs: StringInterface{
PropertyFieldAttributeOptions: make(chan int),
},
}}
result, err := CPAFieldsFromPropertyFields(input)
require.Error(t, err)
assert.Nil(t, result)
})
t.Run("preserves empty visibility from PropertyField (defaults are applied at write time by AccessControlAttributeValidationHook, not at read time)", func(t *testing.T) {
input := []*PropertyField{{
ID: NewId(),
GroupID: AccessControlPropertyGroupName,
Name: "no_visibility",
Type: PropertyFieldTypeText,
Attrs: StringInterface{},
}}
result, err := CPAFieldsFromPropertyFields(input)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Empty(t, result[0].Attrs.Visibility)
})
}