Files
team/server/channels/app/properties/access_control_value_test.go
de986ae735 Add property system app layer architecture (#35157)
* Refactor property system with app layer routing and access control separation

Establish the app layer as the primary entry point for property operations
with intelligent routing based on group type. This architecture separates
access-controlled operations (CPA groups) from standard operations,
improving performance and code clarity.

Architecture Changes:
- App layer now routes operations based on group type:
  - CPA groups -> PropertyAccessService (enforces access control)
  - Non-CPA groups -> PropertyService (direct, no access control)
- PropertyAccessService simplified to handle only CPA operations
- Eliminated redundant group type checks throughout the codebase

* Move access control routing into PropertyService

This change makes the PropertyService the main entrypoint for property
related operations, and adds a routing mechanism to decide if extra
behaviors or checks should run for each operation, in this case, the
property access service logic.

To add specific payloads that pluggable checks and operations may
need, we use the request context. When the request comes from the API,
the endpoints are in charge of adding the caller ID to the payload,
and in the case of the plugin API, on receiving a request, the server
automatically tags the context with the plugin ID so the property
service can react accordingly.

Finally, the new design enforces all these checks migrating the actual
property logic to internal, non-exposed methods, so any caller from
the App layer needs to go through the service checks that decide if
pluggable logic is needed, avoiding any possibility of a bypass.

* Fix i18n

* Fix bad error string

* Added nil guards to property methods

* Add check for multiple group IDs on value operations

* Add nil guard to the plugin checker

* Fix build error

* Update value tests

* Fix linter

* Adds early return when content flaggin a thread with no replies

* Fix mocks

* Clean the state of plugin property tests before each run

* Do not wrap appErr on API response and fix i18n

* Fix create property field test

* Remove the need to cache cpaGroupID as part of the property service

* Split the property.go file into multiple

* Not found group doesn't bypass access control check

* Unexport SetPluginCheckerForTests

* Rename plugin context getter to be more PSA specific

---------

Co-authored-by: Miguel de la Cruz <[email protected]>
2026-03-26 07:54:50 +00:00

2232 lines
72 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCreatePropertyValue_WriteAccessControl tests write access control for value creation
func TestCreatePropertyValue_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows creating value for public field", func(t *testing.T) {
field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin2, value)
require.NoError(t, err)
assert.NotNil(t, createdValue)
})
t.Run("allows source plugin to create value for source_only field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "SourceOnly",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"secret value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
assert.NotNil(t, createdValue)
})
t.Run("denies creating value for protected field by non-source", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin2, value)
require.Error(t, err)
assert.Nil(t, createdValue)
assert.Contains(t, err.Error(), "protected")
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-value-create")
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "Non-CPA Value Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create value with different plugin - should be allowed (no access control)
value := &model.PropertyValue{
GroupID: nonCpaGroup.ID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin2, value)
require.NoError(t, err)
assert.NotNil(t, createdValue)
})
}
// TestDeletePropertyValue_WriteAccessControl tests write access control for value deletion
func TestDeletePropertyValue_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows deleting value for public field", func(t *testing.T) {
field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
err = th.service.DeletePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue.ID)
require.NoError(t, err)
})
t.Run("denies non-source deleting value for protected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
err = th.service.DeletePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
})
}
// TestDeletePropertyValuesForTarget_WriteAccessControl tests bulk deletion with access control
func TestDeletePropertyValuesForTarget_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows deleting all values when caller has write access to all fields", func(t *testing.T) {
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText}
field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
require.NoError(t, err)
targetID := model.NewId()
value1 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created1.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v1"`)}
value2 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created2.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v2"`)}
_, err = th.service.CreatePropertyValue(rctxPlugin1, value1)
require.NoError(t, err)
_, err = th.service.CreatePropertyValue(rctxPlugin1, value2)
require.NoError(t, err)
err = th.service.DeletePropertyValuesForTarget(rctxPlugin2, th.CPAGroupID, "user", targetID)
require.NoError(t, err)
})
t.Run("fails atomically when caller lacks access to one field", func(t *testing.T) {
// Create public field
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
// Create protected field
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
require.NoError(t, err)
targetID := model.NewId()
value1 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created1.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v1"`)}
value2 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created2.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v2"`)}
_, err = th.service.CreatePropertyValue(rctxPlugin1, value1)
require.NoError(t, err)
_, err = th.service.CreatePropertyValue(rctxPlugin1, value2)
require.NoError(t, err)
// Try to delete with plugin2 (should fail)
err = th.service.DeletePropertyValuesForTarget(rctxPlugin2, th.CPAGroupID, "user", targetID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
// Verify values still exist
values, err := th.service.SearchPropertyValues(rctxPlugin1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 10,
})
require.NoError(t, err)
assert.Len(t, values, 2)
})
}
func TestGetPropertyValueReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
userID1 := model.NewId()
userID2 := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxUser2 := RequestContextWithCallerID(th.Context, userID2)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
t.Run("public field value - any caller can read", func(t *testing.T) {
// Create public field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Create value
textValue, jsonErr := json.Marshal("test value")
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: textValue,
}
value, err = th.service.CreatePropertyValue(rctxAnon, value)
require.NoError(t, err)
// Plugin 1 can read
retrieved, err := th.service.GetPropertyValue(rctx1, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(textValue), retrieved.Value)
// Plugin 2 can read
retrieved, err = th.service.GetPropertyValue(rctx2, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
// User can read
retrieved, err = th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
// Anonymous caller can read
retrieved, err = th.service.GetPropertyValue(rctxAnon, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
})
t.Run("source_only field value - only source plugin can read", func(t *testing.T) {
// Create source_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Create value
textValue, jsonErr := json.Marshal("secret value")
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: textValue,
}
value, err = th.service.CreatePropertyValue(rctx1, value)
require.NoError(t, err)
// Source plugin can read
retrieved, err := th.service.GetPropertyValue(rctx1, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(textValue), retrieved.Value)
})
t.Run("source_only field value - other plugin gets nil", func(t *testing.T) {
// Create source_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Create value
textValue, jsonErr := json.Marshal("secret value")
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: textValue,
}
value, err = th.service.CreatePropertyValue(rctx1, value)
require.NoError(t, err)
// Other plugin gets nil
retrieved, err := th.service.GetPropertyValue(rctx2, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
// User gets nil
retrieved, err = th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
// Anonymous caller gets nil
retrieved, err = th.service.GetPropertyValue(rctxAnon, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("shared_only single-select - return value only if caller has same", func(t *testing.T) {
// Create shared_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-single-select",
Type: model.PropertyFieldTypeSelect,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
map[string]any{"id": "opt3", "value": "Option 3"},
},
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// User 1 has opt1
user1Value, jsonErr := json.Marshal("opt1")
require.NoError(t, jsonErr)
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: user1Value,
}
value1, err = th.service.CreatePropertyValue(rctxTestPlugin, value1)
require.NoError(t, err)
// User 2 also has opt1
user2Value, jsonErr := json.Marshal("opt1")
require.NoError(t, jsonErr)
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID2,
Value: user2Value,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, value2)
require.NoError(t, err)
// User 2 can see user 1's value (both have opt1)
retrieved, err := th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value1.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value1.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(user1Value), retrieved.Value)
// Create another user with opt2
userID3 := model.NewId()
user3Value, jsonErr := json.Marshal("opt2")
require.NoError(t, jsonErr)
value3 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID3,
Value: user3Value,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, value3)
require.NoError(t, err)
// User 3 cannot see user 1's value (different options, no intersection)
retrieved, err = th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, userID3), th.CPAGroupID, value1.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("shared_only multi-select - return intersection of arrays", func(t *testing.T) {
// Create shared_only multiselect field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-multi-select",
Type: model.PropertyFieldTypeMultiselect,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Hiking"},
map[string]any{"id": "opt2", "value": "Cooking"},
map[string]any{"id": "opt3", "value": "Gaming"},
},
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// Alice has ["opt1", "opt2"] (hiking, cooking)
aliceID := model.NewId()
aliceValue, jsonErr := json.Marshal([]string{"opt1", "opt2"})
require.NoError(t, jsonErr)
alicePropertyValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: aliceID,
Value: aliceValue,
}
alicePropertyValue, err = th.service.CreatePropertyValue(rctxTestPlugin, alicePropertyValue)
require.NoError(t, err)
// Bob has ["opt1", "opt3"] (hiking, gaming)
bobID := model.NewId()
bobValue, jsonErr := json.Marshal([]string{"opt1", "opt3"})
require.NoError(t, jsonErr)
bobPropertyValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: bobID,
Value: bobValue,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, bobPropertyValue)
require.NoError(t, err)
// Bob views Alice - should only see ["opt1"] (intersection)
retrieved, err := th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, bobID), th.CPAGroupID, alicePropertyValue.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, alicePropertyValue.ID, retrieved.ID)
var retrievedOptions []string
jsonErr = json.Unmarshal(retrieved.Value, &retrievedOptions)
require.NoError(t, jsonErr)
assert.Len(t, retrievedOptions, 1)
assert.Contains(t, retrievedOptions, "opt1")
// Create user with no overlapping values
charlieID := model.NewId()
charlieValue, jsonErr := json.Marshal([]string{"opt3"})
require.NoError(t, jsonErr)
charliePropertyValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: charlieID,
Value: charlieValue,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, charliePropertyValue)
require.NoError(t, err)
// Charlie views Alice - should get nil (no intersection)
retrieved, err = th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, charlieID), th.CPAGroupID, alicePropertyValue.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("shared_only value - caller with no values sees nothing", func(t *testing.T) {
// Create shared_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-no-values",
Type: model.PropertyFieldTypeMultiselect,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
},
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// Create value for user 1
user1Value, jsonErr := json.Marshal([]string{"opt1"})
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: user1Value,
}
value, err = th.service.CreatePropertyValue(rctxTestPlugin, value)
require.NoError(t, err)
// User 2 has no values for this field
retrieved, err := th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("non-CPA group routes directly to PropertyService without filtering", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-value-read")
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-value-source-only",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: pluginID1,
},
}
created, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
targetID := model.NewId()
value := &model.PropertyValue{
GroupID: nonCpaGroup.ID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"visible"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxTestPlugin, value)
require.NoError(t, err)
// Other plugin can read (no filtering, goes directly to PropertyService)
rctx2Local := RequestContextWithCallerID(th.Context, "plugin-2")
retrieved, err := th.service.GetPropertyValue(rctx2Local, nonCpaGroup.ID, createdValue.ID)
require.NoError(t, err)
assert.NotNil(t, retrieved)
// User can also read (no filtering, goes directly to PropertyService)
rctxUser := RequestContextWithCallerID(th.Context, model.NewId())
retrievedByUser, err := th.service.GetPropertyValue(rctxUser, nonCpaGroup.ID, createdValue.ID)
require.NoError(t, err)
assert.NotNil(t, retrievedByUser)
})
}
func TestGetPropertyValuesReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
userID := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxUser := RequestContextWithCallerID(th.Context, userID)
t.Run("mixed access modes - bulk read respects per-field access control", func(t *testing.T) {
// Create public field
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-bulk",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create source_only field
sourceOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-bulk",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
sourceOnlyField, err = th.service.CreatePropertyField(rctx1, sourceOnlyField)
require.NoError(t, err)
// Create values
publicValue, jsonErr := json.Marshal("public")
require.NoError(t, jsonErr)
publicPropValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: userID,
Value: publicValue,
}
publicPropValue, err = th.service.CreatePropertyValue(rctxAnon, publicPropValue)
require.NoError(t, err)
sourceOnlyValue, jsonErr := json.Marshal("secret")
require.NoError(t, jsonErr)
sourceOnlyPropValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sourceOnlyField.ID,
TargetType: "user",
TargetID: userID,
Value: sourceOnlyValue,
}
sourceOnlyPropValue, err = th.service.CreatePropertyValue(rctx1, sourceOnlyPropValue)
require.NoError(t, err)
// Plugin 1 (source) sees both values
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{publicPropValue.ID, sourceOnlyPropValue.ID})
require.NoError(t, err)
assert.Len(t, retrieved, 2)
// Plugin 2 sees only public value
retrieved, err = th.service.GetPropertyValues(rctx2, th.CPAGroupID, []string{publicPropValue.ID, sourceOnlyPropValue.ID})
require.NoError(t, err)
assert.Len(t, retrieved, 1)
assert.Equal(t, publicPropValue.ID, retrieved[0].ID)
// User sees only public value
retrieved, err = th.service.GetPropertyValues(rctxUser, th.CPAGroupID, []string{publicPropValue.ID, sourceOnlyPropValue.ID})
require.NoError(t, err)
assert.Len(t, retrieved, 1)
assert.Equal(t, publicPropValue.ID, retrieved[0].ID)
})
}
func TestSearchPropertyValuesReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
userID1 := model.NewId()
userID2 := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxUser2 := RequestContextWithCallerID(th.Context, userID2)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
t.Run("search filters based on field access", func(t *testing.T) {
// Create public field
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-search",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create source_only field
sourceOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-search",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
sourceOnlyField, err = th.service.CreatePropertyField(rctx1, sourceOnlyField)
require.NoError(t, err)
// Create values for both fields
publicValue, jsonErr := json.Marshal("public data")
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxAnon, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: userID1,
Value: publicValue,
})
require.NoError(t, err)
sourceOnlyValue, jsonErr := json.Marshal("secret data")
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctx1, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sourceOnlyField.ID,
TargetType: "user",
TargetID: userID1,
Value: sourceOnlyValue,
})
require.NoError(t, err)
// Source plugin sees both values
results, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{userID1},
PerPage: 100,
})
require.NoError(t, err)
assert.Len(t, results, 2)
// Other plugin sees only public value
results, err = th.service.SearchPropertyValues(rctx2, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{userID1},
PerPage: 100,
})
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, publicField.ID, results[0].FieldID)
})
t.Run("search shared_only values show intersection", func(t *testing.T) {
// Create shared_only field
sharedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-field-search",
Type: model.PropertyFieldTypeMultiselect,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
map[string]any{"id": "opt3", "value": "Option 3"},
},
},
}
sharedField, err := th.service.CreatePropertyField(rctxTestPlugin, sharedField)
require.NoError(t, err)
// User 1 has ["opt1", "opt2"]
user1Value, jsonErr := json.Marshal([]string{"opt1", "opt2"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sharedField.ID,
TargetType: "user",
TargetID: userID1,
Value: user1Value,
})
require.NoError(t, err)
// User 2 has ["opt1", "opt3"]
user2Value, jsonErr := json.Marshal([]string{"opt1", "opt3"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sharedField.ID,
TargetType: "user",
TargetID: userID2,
Value: user2Value,
})
require.NoError(t, err)
// User 2 searches for user 1's values - should see only ["opt1"]
results, err := th.service.SearchPropertyValues(rctxUser2, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{userID1},
FieldID: sharedField.ID,
PerPage: 100,
})
require.NoError(t, err)
require.Len(t, results, 1)
var retrievedOptions []string
jsonErr = json.Unmarshal(results[0].Value, &retrievedOptions)
require.NoError(t, jsonErr)
assert.Len(t, retrievedOptions, 1)
assert.Contains(t, retrievedOptions, "opt1")
})
}
// TestCreatePropertyValues_WriteAccessControl tests write access control for bulk value creation
func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
t.Run("allows creating values for public fields", func(t *testing.T) {
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field1, err := th.service.CreatePropertyField(rctxAnon, field1)
require.NoError(t, err)
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field2, err = th.service.CreatePropertyField(rctxAnon, field2)
require.NoError(t, err)
targetID := model.NewId()
value1, v1err := json.Marshal("value1")
require.NoError(t, v1err)
value2, v2err := json.Marshal("value2")
require.NoError(t, v2err)
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: field1.ID,
TargetType: "user",
TargetID: targetID,
Value: value1,
},
{
GroupID: th.CPAGroupID,
FieldID: field2.ID,
TargetType: "user",
TargetID: targetID,
Value: value2,
},
}
created, cerr := th.service.CreatePropertyValues(rctx2, values)
require.NoError(t, cerr)
assert.Len(t, created, 2)
})
t.Run("allows source plugin to create values for protected fields", func(t *testing.T) {
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field2, err = th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
targetID := model.NewId()
value1, v1err := json.Marshal("secret1")
require.NoError(t, v1err)
value2, v2err := json.Marshal("secret2")
require.NoError(t, v2err)
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: field1.ID,
TargetType: "user",
TargetID: targetID,
Value: value1,
},
{
GroupID: th.CPAGroupID,
FieldID: field2.ID,
TargetType: "user",
TargetID: targetID,
Value: value2,
},
}
created, cerr := th.service.CreatePropertyValues(rctx1, values)
require.NoError(t, cerr)
assert.Len(t, created, 2)
})
t.Run("fails atomically when one protected field in batch", func(t *testing.T) {
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-batch",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
protectedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-batch",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
protectedField, err = th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
targetID := model.NewId()
publicValue, jsonErr := json.Marshal("public data")
require.NoError(t, jsonErr)
protectedValue, jsonErr := json.Marshal("secret data")
require.NoError(t, jsonErr)
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: targetID,
Value: publicValue,
},
{
GroupID: th.CPAGroupID,
FieldID: protectedField.ID,
TargetType: "user",
TargetID: targetID,
Value: protectedValue,
},
}
// Plugin 2 should fail to create both values atomically
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "protected")
// Verify neither value was created
results, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, results)
})
t.Run("rejects values across multiple groups", func(t *testing.T) {
// Register a second group
group2, err := th.service.RegisterPropertyGroup("test-group-create-values-2")
require.NoError(t, err)
// Create fields in both groups
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "field-group1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field1, err = th.service.CreatePropertyField(rctxAnon, field1)
require.NoError(t, err)
field2 := &model.PropertyField{
GroupID: group2.ID,
Name: "field-group2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field2, err = th.service.CreatePropertyField(rctxAnon, field2)
require.NoError(t, err)
targetID := model.NewId()
value1, jsonErr := json.Marshal("data from group 1")
require.NoError(t, jsonErr)
value2, jsonErr := json.Marshal("data from group 2")
require.NoError(t, jsonErr)
// Creating values for fields from different groups in a single call should fail
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: field1.ID,
TargetType: "user",
TargetID: targetID,
Value: value1,
},
{
GroupID: group2.ID,
FieldID: field2.ID,
TargetType: "user",
TargetID: targetID,
Value: value2,
},
}
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "mixed group IDs in batch")
})
t.Run("rejects mixed groups before checking access control", func(t *testing.T) {
// Register a third group
group3, err := th.service.RegisterPropertyGroup("test-group-create-values-3")
require.NoError(t, err)
// Create public field in CPA group
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-multigroup",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err = th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create protected field in group 3
protectedField := &model.PropertyField{
GroupID: group3.ID,
Name: "protected-field-multigroup",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
protectedField, err = th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
targetID := model.NewId()
publicValue, jsonErr := json.Marshal("public data")
require.NoError(t, jsonErr)
protectedValue, jsonErr := json.Marshal("secret data")
require.NoError(t, jsonErr)
// Mixed-group batch is rejected before access control is even checked
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: targetID,
Value: publicValue,
},
{
GroupID: group3.ID,
FieldID: protectedField.ID,
TargetType: "user",
TargetID: targetID,
Value: protectedValue,
},
}
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "mixed group IDs in batch")
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
// Register a non-CPA group
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-bulk")
require.NoError(t, err)
// Create two fields in non-CPA group
field1 := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-bulk-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
field2 := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-bulk-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Create values for both fields with different plugin - should be allowed (no access control)
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: nonCpaGroup.ID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value1"`),
},
{
GroupID: nonCpaGroup.ID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value2"`),
},
}
createdValues, err := th.service.CreatePropertyValues(rctx2, values)
require.NoError(t, err)
assert.Len(t, createdValues, 2)
})
t.Run("mixed CPA and non-CPA groups are rejected before access control", func(t *testing.T) {
// Register a non-CPA group
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-mixed")
require.NoError(t, err)
// Create protected field in CPA group via plugin API
cpaField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "cpa-protected-mixed",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
cpaField, err = th.service.CreatePropertyField(rctx1, cpaField)
require.NoError(t, err)
// Create field in non-CPA group (no access control attributes)
nonCpaField := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-field-mixed",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
nonCpaField, err = th.service.CreatePropertyField(rctx1, nonCpaField)
require.NoError(t, err)
// Try to bulk create values for BOTH groups — rejected by group-consistency check
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: cpaField.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"cpa data"`),
},
{
GroupID: nonCpaGroup.ID,
FieldID: nonCpaField.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"non-cpa data"`),
},
}
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "mixed group IDs in batch")
})
}
func TestUpdatePropertyValue_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can update values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-for-update",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create a value
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"original"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Source plugin can update the value
createdValue.Value = json.RawMessage(`"updated"`)
updated, err := th.service.UpdatePropertyValue(rctxPlugin1, th.CPAGroupID, createdValue)
require.NoError(t, err)
assert.NotNil(t, updated)
assert.Equal(t, `"updated"`, string(updated.Value))
})
t.Run("non-source plugin cannot update values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-for-update-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create a value with plugin1
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"original"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Different plugin cannot update
createdValue.Value = json.RawMessage(`"hacked"`)
updated, err := th.service.UpdatePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
})
t.Run("any caller can update values for non-protected field", func(t *testing.T) {
// Create a non-protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-for-update",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Create a value
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"original"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Different plugin can update
createdValue.Value = json.RawMessage(`"updated by plugin2"`)
updated, err := th.service.UpdatePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue)
require.NoError(t, err)
assert.NotNil(t, updated)
assert.Equal(t, `"updated by plugin2"`, string(updated.Value))
})
}
func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can update multiple values atomically", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Create values
targetID := model.NewId()
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value1"`),
}
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value2"`),
}
createdValues, err := th.service.CreatePropertyValues(rctx1, []*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, createdValues, 2)
// Update both values
createdValues[0].Value = json.RawMessage(`"updated1"`)
createdValues[1].Value = json.RawMessage(`"updated2"`)
updated, err := th.service.UpdatePropertyValues(rctx1, th.CPAGroupID, createdValues)
require.NoError(t, err)
assert.Len(t, updated, 2)
})
t.Run("non-source plugin cannot update values atomically", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-fail-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-fail-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Create values
targetID := model.NewId()
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value1"`),
}
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value2"`),
}
createdValues, err := th.service.CreatePropertyValues(rctx1, []*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, createdValues, 2)
// Try to update both values with different plugin - should fail atomically
createdValues[0].Value = json.RawMessage(`"hacked1"`)
createdValues[1].Value = json.RawMessage(`"hacked2"`)
updated, err := th.service.UpdatePropertyValues(rctx2, th.CPAGroupID, createdValues)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify values were NOT updated
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValues[0].ID, createdValues[1].ID})
require.NoError(t, err)
assert.Equal(t, `"value1"`, string(retrieved[0].Value))
assert.Equal(t, `"value2"`, string(retrieved[1].Value))
})
t.Run("mixed protected and non-protected fields - enforces access control only on protected fields", func(t *testing.T) {
// Create one protected field and one non-protected field
protectedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-update-protected-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-update-public-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
createdProtected, err := th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
createdPublic, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create values for both fields with plugin1
targetID := model.NewId()
protectedValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdProtected.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"protected value"`),
}
publicValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdPublic.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"public value"`),
}
createdValues, err := th.service.CreatePropertyValues(rctx1, []*model.PropertyValue{protectedValue, publicValue})
require.NoError(t, err)
require.Len(t, createdValues, 2)
// Try to update both values with plugin2 - should fail atomically
createdValues[0].Value = json.RawMessage(`"hacked protected"`)
createdValues[1].Value = json.RawMessage(`"hacked public"`)
updated, err := th.service.UpdatePropertyValues(rctx2, th.CPAGroupID, createdValues)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify NO values were updated (atomic failure)
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValues[0].ID, createdValues[1].ID})
require.NoError(t, err)
assert.Equal(t, `"protected value"`, string(retrieved[0].Value))
assert.Equal(t, `"public value"`, string(retrieved[1].Value))
// Now try with source plugin - should succeed for both
createdValues[0].Value = json.RawMessage(`"updated protected"`)
createdValues[1].Value = json.RawMessage(`"updated public"`)
updated, err = th.service.UpdatePropertyValues(rctx1, th.CPAGroupID, createdValues)
require.NoError(t, err)
assert.Len(t, updated, 2)
assert.Equal(t, `"updated protected"`, string(updated[0].Value))
assert.Equal(t, `"updated public"`, string(updated[1].Value))
})
t.Run("multiple protected fields with different owners - enforces access control atomically", func(t *testing.T) {
// Create two protected fields, each owned by a different plugin
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "multi-owner-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "multi-owner-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
createdField1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
createdField2, err := th.service.CreatePropertyField(rctx2, field2)
require.NoError(t, err)
// Create values for both fields (each plugin creates its own)
targetID := model.NewId()
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value from plugin1"`),
}
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value from plugin2"`),
}
createdValue1, err := th.service.CreatePropertyValue(rctx1, value1)
require.NoError(t, err)
createdValue2, err := th.service.CreatePropertyValue(rctx2, value2)
require.NoError(t, err)
// Try to update both values with plugin1 - should fail because it doesn't own field2
createdValue1.Value = json.RawMessage(`"updated by plugin1"`)
createdValue2.Value = json.RawMessage(`"hacked by plugin1"`)
updated, err := th.service.UpdatePropertyValues(rctx1, th.CPAGroupID, []*model.PropertyValue{createdValue1, createdValue2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify NO values were updated (atomic failure)
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValue1.ID, createdValue2.ID})
require.NoError(t, err)
assert.Equal(t, `"value from plugin1"`, string(retrieved[0].Value))
assert.Equal(t, `"value from plugin2"`, string(retrieved[1].Value))
// Try to update both values with plugin2 - should also fail because it doesn't own field1
createdValue1.Value = json.RawMessage(`"hacked by plugin2"`)
createdValue2.Value = json.RawMessage(`"updated by plugin2"`)
updated, err = th.service.UpdatePropertyValues(rctx2, th.CPAGroupID, []*model.PropertyValue{createdValue1, createdValue2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify still NO values were updated
retrieved, err = th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValue1.ID, createdValue2.ID})
require.NoError(t, err)
assert.Equal(t, `"value from plugin1"`, string(retrieved[0].Value))
assert.Equal(t, `"value from plugin2"`, string(retrieved[1].Value))
// Each plugin can update its own value individually
createdValue1.Value = json.RawMessage(`"plugin-1 updated its own"`)
updated1, err := th.service.UpdatePropertyValue(rctx1, th.CPAGroupID, createdValue1)
require.NoError(t, err)
assert.Equal(t, `"plugin-1 updated its own"`, string(updated1.Value))
createdValue2.Value = json.RawMessage(`"plugin-2 updated its own"`)
updated2, err := th.service.UpdatePropertyValue(rctx2, th.CPAGroupID, createdValue2)
require.NoError(t, err)
assert.Equal(t, `"plugin-2 updated its own"`, string(updated2.Value))
})
}
func TestUpsertPropertyValue_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("source plugin can upsert value for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-protected-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Upsert value (create)
targetID := model.NewId()
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"first"`),
}
upserted, err := th.service.UpsertPropertyValue(rctxPlugin1, value)
require.NoError(t, err)
assert.NotNil(t, upserted)
// Upsert again (update)
value.Value = json.RawMessage(`"second"`)
upserted2, err := th.service.UpsertPropertyValue(rctxPlugin1, value)
require.NoError(t, err)
assert.Equal(t, `"second"`, string(upserted2.Value))
})
t.Run("non-source plugin cannot upsert value for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-protected-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Try to upsert value with different plugin
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"unauthorized"`),
}
upserted, err := th.service.UpsertPropertyValue(rctxPlugin2, value)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
})
}
func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can bulk upsert values for protected fields", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Bulk upsert values
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"upsert1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"upsert2"`),
},
}
upserted, err := th.service.UpsertPropertyValues(rctx1, values)
require.NoError(t, err)
assert.Len(t, upserted, 2)
})
t.Run("non-source plugin cannot bulk upsert values atomically", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-fail-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-fail-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Try to bulk upsert with different plugin
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"unauthorized1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"unauthorized2"`),
},
}
upserted, err := th.service.UpsertPropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify no values were created
retrieved, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
})
t.Run("mixed protected and non-protected fields - enforces access control only on protected fields", func(t *testing.T) {
// Create one protected field and one non-protected field
protectedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-protected-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-public-field",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
createdProtected, err := th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
createdPublic, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Try to upsert values for both fields with plugin2
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: createdProtected.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"protected value"`),
},
{
GroupID: th.CPAGroupID,
FieldID: createdPublic.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"public value"`),
},
}
// Should fail atomically - plugin2 cannot upsert value for protected field
upserted, err := th.service.UpsertPropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify no values were created (atomic failure)
retrieved, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
// Now try with source plugin - should succeed for both
upserted, err = th.service.UpsertPropertyValues(rctx1, values)
require.NoError(t, err)
assert.Len(t, upserted, 2)
})
t.Run("multiple protected fields with different owners - enforces access control atomically", func(t *testing.T) {
// Create two protected fields, each owned by a different plugin
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-multi-owner-field-1",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-multi-owner-field-2",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
createdField1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
createdField2, err := th.service.CreatePropertyField(rctx2, field2)
require.NoError(t, err)
// Try to upsert values for both fields with plugin1 - should fail because it doesn't own field2
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: createdField1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value from plugin1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: createdField2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"hacked by plugin1"`),
},
}
upserted, err := th.service.UpsertPropertyValues(rctx1, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify no values were created (atomic failure)
retrieved, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
// Try to upsert both values with plugin2 - should also fail because it doesn't own field1
values[0].Value = json.RawMessage(`"hacked by plugin2"`)
values[1].Value = json.RawMessage(`"value from plugin2"`)
upserted, err = th.service.UpsertPropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify still no values were created
retrieved, err = th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
// Each plugin can upsert its own value individually
upserted1, err := th.service.UpsertPropertyValue(rctx1, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"plugin-1 upserted its own"`),
})
require.NoError(t, err)
assert.Equal(t, `"plugin-1 upserted its own"`, string(upserted1.Value))
upserted2, err := th.service.UpsertPropertyValue(rctx2, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"plugin-2 upserted its own"`),
})
require.NoError(t, err)
assert.Equal(t, `"plugin-2 upserted its own"`, string(upserted2.Value))
})
}
func TestDeletePropertyValuesForField_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can delete all values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "field-delete-values",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create multiple values
targetID1 := model.NewId()
targetID2 := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID1,
Value: json.RawMessage(`"value1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID2,
Value: json.RawMessage(`"value2"`),
},
}
_, err = th.service.CreatePropertyValues(rctxPlugin1, values)
require.NoError(t, err)
// Source plugin can delete all values for the field
err = th.service.DeletePropertyValuesForField(rctxPlugin1, th.CPAGroupID, created.ID)
require.NoError(t, err)
// Verify values are deleted
retrieved, err := th.service.SearchPropertyValues(rctxPlugin1, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
})
t.Run("non-source plugin cannot delete values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "field-delete-values-fail",
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create a value
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"protected value"`),
}
_, err = th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Different plugin cannot delete values
err = th.service.DeletePropertyValuesForField(rctxPlugin2, th.CPAGroupID, created.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
// Verify value still exists
retrieved, err := th.service.SearchPropertyValues(rctxPlugin1, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 100,
})
require.NoError(t, err)
assert.Len(t, retrieved, 1)
})
t.Run("any caller can delete values for non-protected field", func(t *testing.T) {
// Create a non-protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-delete-values",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Create values with plugin1
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"value1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"value2"`),
},
}
_, err = th.service.CreatePropertyValues(rctxPlugin1, values)
require.NoError(t, err)
// Different plugin can delete values
err = th.service.DeletePropertyValuesForField(rctxPlugin2, th.CPAGroupID, created.ID)
require.NoError(t, err)
// Verify values are deleted
retrieved, err := th.service.SearchPropertyValues(rctxPlugin2, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
})
}
func TestCreatePropertyValue(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil value returns error", func(t *testing.T) {
_, err := th.service.CreatePropertyValue(th.Context, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "CreatePropertyValue: value cannot be nil")
})
}
func TestCreatePropertyValues(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil element returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
nil,
}
_, err := th.service.CreatePropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "CreatePropertyValues: nil element at index 1")
})
t.Run("mixed group IDs returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
{GroupID: model.NewId()},
}
_, err := th.service.CreatePropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "CreatePropertyValues: mixed group IDs in batch")
})
}
func TestUpsertPropertyValue(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil value returns error", func(t *testing.T) {
_, err := th.service.UpsertPropertyValue(th.Context, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "UpsertPropertyValue: value cannot be nil")
})
}
func TestUpsertPropertyValues(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil element returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
nil,
}
_, err := th.service.UpsertPropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "UpsertPropertyValues: nil element at index 1")
})
t.Run("mixed group IDs returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
{GroupID: model.NewId()},
}
_, err := th.service.UpsertPropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "UpsertPropertyValues: mixed group IDs in batch")
})
}