Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

638 lines
22 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"fmt"
"testing"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testHook is a configurable PropertyHook implementation for testing hook
// registration, ordering, chaining, and blocking behavior. It embeds
// BasePropertyHook for default passthrough behavior and only overrides
// methods where a test-specific function is set.
type testHook struct {
BasePropertyHook
preCreateFieldFn func(*model.PropertyField) (*model.PropertyField, error)
preUpdateFieldFn func(string, *model.PropertyField) (*model.PropertyField, error)
preUpdateFieldsFn func(string, []*model.PropertyField) ([]*model.PropertyField, error)
preDeleteFieldFn func(string, string) error
postUpdateFieldsFn func(string, []*model.PropertyField, []*model.PropertyField, []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error)
postGetFieldFn func(*model.PropertyField) (*model.PropertyField, error)
postGetFieldsFn func([]*model.PropertyField) ([]*model.PropertyField, error)
preUpsertValueFn func(*model.PropertyValue) (*model.PropertyValue, error)
preUpsertValuesFn func([]*model.PropertyValue) ([]*model.PropertyValue, error)
postGetValueFn func(*model.PropertyValue) (*model.PropertyValue, error)
postGetValuesFn func([]*model.PropertyValue) ([]*model.PropertyValue, error)
}
func (h *testHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
if h.preCreateFieldFn != nil {
return h.preCreateFieldFn(field)
}
return field, nil
}
func (h *testHook) PreUpdatePropertyField(_ request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
if h.preUpdateFieldFn != nil {
return h.preUpdateFieldFn(groupID, field)
}
return field, nil
}
func (h *testHook) PreUpdatePropertyFields(_ request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
if h.preUpdateFieldsFn != nil {
return h.preUpdateFieldsFn(groupID, fields)
}
return fields, nil
}
func (h *testHook) PreDeletePropertyField(_ request.CTX, groupID string, id string) error {
if h.preDeleteFieldFn != nil {
return h.preDeleteFieldFn(groupID, id)
}
return nil
}
func (h *testHook) PostUpdatePropertyFields(_ request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
if h.postUpdateFieldsFn != nil {
return h.postUpdateFieldsFn(groupID, prev, requested, propagated)
}
return requested, propagated, nil, nil
}
func (h *testHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
if h.postGetFieldFn != nil {
return h.postGetFieldFn(field)
}
return field, nil
}
func (h *testHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
if h.postGetFieldsFn != nil {
return h.postGetFieldsFn(fields)
}
return fields, nil
}
func (h *testHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if h.preUpsertValueFn != nil {
return h.preUpsertValueFn(value)
}
return value, nil
}
func (h *testHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if h.preUpsertValuesFn != nil {
return h.preUpsertValuesFn(values)
}
return values, nil
}
func (h *testHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if h.postGetValueFn != nil {
return h.postGetValueFn(value)
}
return value, nil
}
func (h *testHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if h.postGetValuesFn != nil {
return h.postGetValuesFn(values)
}
return values, nil
}
func TestHookRegistration(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("service starts with no hooks", func(t *testing.T) {
service, err := New(ServiceConfig{
PropertyGroupStore: th.dbStore.PropertyGroup(),
PropertyFieldStore: th.dbStore.PropertyField(),
PropertyValueStore: th.dbStore.PropertyValue(),
})
require.NoError(t, err)
assert.Empty(t, service.hooks)
})
t.Run("AddHook appends hooks in order", func(t *testing.T) {
service, err := New(ServiceConfig{
PropertyGroupStore: th.dbStore.PropertyGroup(),
PropertyFieldStore: th.dbStore.PropertyField(),
PropertyValueStore: th.dbStore.PropertyValue(),
})
require.NoError(t, err)
hook1 := &testHook{}
hook2 := &testHook{}
service.AddHook(hook1)
service.AddHook(hook2)
assert.Len(t, service.hooks, 2)
})
}
func TestPreHookBlocking(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("pre-hook error blocks CreatePropertyField", func(t *testing.T) {
hook := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
return nil, fmt.Errorf("blocked by hook")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "blocked-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
_, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Contains(t, err.Error(), "blocked by hook")
})
t.Run("pre-hook error blocks DeletePropertyField", func(t *testing.T) {
hook := &testHook{
preDeleteFieldFn: func(gid string, id string) error {
return fmt.Errorf("delete blocked")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
err := th.service.DeletePropertyField(rctx, groupID, model.NewId())
require.Error(t, err)
assert.Contains(t, err.Error(), "delete blocked")
})
t.Run("pre-hook error blocks UpsertPropertyValue", func(t *testing.T) {
hook := &testHook{
preUpsertValueFn: func(value *model.PropertyValue) (*model.PropertyValue, error) {
return nil, fmt.Errorf("upsert blocked")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
value := &model.PropertyValue{
GroupID: groupID,
FieldID: model.NewId(),
}
_, err := th.service.UpsertPropertyValue(rctx, value)
require.Error(t, err)
assert.Contains(t, err.Error(), "upsert blocked")
})
}
func TestPreHookInputModification(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("pre-hook modifies field before creation", func(t *testing.T) {
hook := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
// Modify the field name
field.Name = "modified-" + field.Name
return field, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "original",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.Equal(t, "modified-original", result.Name)
})
}
func TestPostHookFiltering(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("post-hook returning nil field without error surfaces errNilHookResult", func(t *testing.T) {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "nil-return-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
hook := &testHook{
postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
return nil, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
result, err := th.service.GetPropertyField(rctx, groupID, field.ID)
require.ErrorIs(t, err, errNilHookResult)
assert.Nil(t, result)
})
t.Run("post-hook that drops fields from list returns error", func(t *testing.T) {
field1 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "keep-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field2 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "remove-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
hook := &testHook{
postGetFieldsFn: func(fields []*model.PropertyField) ([]*model.PropertyField, error) {
filtered := []*model.PropertyField{}
for _, f := range fields {
if f.ID == field1.ID {
filtered = append(filtered, f)
}
}
return filtered, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
_, err := th.service.GetPropertyFields(rctx, groupID, []string{field1.ID, field2.ID})
require.Error(t, err)
assert.Contains(t, err.Error(), "fewer fields")
})
}
func TestMultipleHooksChaining(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("multiple pre-hooks chain modifications in order", func(t *testing.T) {
order := []string{}
hook1 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
order = append(order, "hook1")
field.Name = field.Name + "-h1"
return field, nil
},
}
hook2 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
order = append(order, "hook2")
field.Name = field.Name + "-h2"
return field, nil
},
}
th.service.AddHook(hook1)
th.service.AddHook(hook2)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "base",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.Equal(t, "base-h1-h2", result.Name)
assert.Equal(t, []string{"hook1", "hook2"}, order)
})
t.Run("first hook error prevents second hook from running", func(t *testing.T) {
hook2Called := false
hook1 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
return nil, fmt.Errorf("hook1 blocked")
},
}
hook2 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
hook2Called = true
return field, nil
},
}
th.service.AddHook(hook1)
th.service.AddHook(hook2)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "should-fail-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
_, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.False(t, hook2Called, "second hook should not have been called")
})
t.Run("multiple post-hooks chain in order", func(t *testing.T) {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "chain-post-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{"step": "0"},
})
hook1 := &testHook{
postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
if f.Attrs == nil {
f.Attrs = make(model.StringInterface)
}
f.Attrs["hook1"] = true
return f, nil
},
}
hook2 := &testHook{
postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
if f.Attrs == nil {
f.Attrs = make(model.StringInterface)
}
f.Attrs["hook2"] = true
return f, nil
},
}
th.service.AddHook(hook1)
th.service.AddHook(hook2)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
result, err := th.service.GetPropertyField(rctx, groupID, field.ID)
require.NoError(t, err)
assert.Equal(t, true, result.Attrs["hook1"])
assert.Equal(t, true, result.Attrs["hook2"])
})
}
func TestAccessControlHookGroupScoping(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
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("access control enforced for managed group (CPA)", func(t *testing.T) {
// Create a protected field in the CPA group via the source plugin
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-managed-" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
// Another plugin should NOT be able to update it (protected)
created.Attrs[model.PropertyAttrsProtected] = true
updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.NoError(t, err)
updated.Name = "attempt-update"
_, _, err = th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, updated)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
})
t.Run("access control NOT enforced for unmanaged group", func(t *testing.T) {
unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_scoping_test", Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
// Create a protected field in an unmanaged group
field := &model.PropertyField{
GroupID: unmanagedGroup.ID,
Name: "protected-unmanaged-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Another plugin CAN update it (no access control for this group)
created.Name = "updated-by-plugin2"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, unmanagedGroup.ID, created)
require.NoError(t, err)
assert.Equal(t, "updated-by-plugin2", updated.Name)
})
t.Run("read filtering applied for managed group", func(t *testing.T) {
// Create a source-only protected field in the CPA group
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-managed-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Source plugin sees all options
result, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created.ID)
require.NoError(t, err)
opts := result.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, opts, 2)
// Other caller sees empty options
result2, err := th.service.GetPropertyField(rctx, th.CPAGroupID, created.ID)
require.NoError(t, err)
opts2 := result2.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, opts2, 0)
})
t.Run("read filtering NOT applied for unmanaged group", func(t *testing.T) {
unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_read_test", Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
// Create a source-only field in an unmanaged group
field := &model.PropertyField{
GroupID: unmanagedGroup.ID,
Name: "source-only-unmanaged-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsSourcePluginID: "plugin-1",
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Non-source caller sees ALL options (no filtering for unmanaged groups)
result, err := th.service.GetPropertyField(rctx, unmanagedGroup.ID, created.ID)
require.NoError(t, err)
opts := result.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, opts, 2)
})
}
func TestPreUpdatePropertyFieldsHook(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("pre-hook error blocks batch UpdatePropertyFields", func(t *testing.T) {
hook := &testHook{
preUpdateFieldsFn: func(gid string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
return nil, fmt.Errorf("batch update blocked")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "batch-block-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field.Name = "updated"
_, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
require.Error(t, err)
assert.Contains(t, err.Error(), "batch update blocked")
})
t.Run("pre-hook modifies fields in batch update", func(t *testing.T) {
hook := &testHook{
preUpdateFieldsFn: func(gid string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
for _, f := range fields {
f.Name = "modified-" + f.Name
}
return fields, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field1 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "batch-a-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field2 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "batch-b-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field1.Name = "a"
field2.Name = "b"
results, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field1, field2})
require.NoError(t, err)
require.Len(t, results, 2)
assert.Equal(t, "modified-a", results[0].Name)
assert.Equal(t, "modified-b", results[1].Name)
})
}
func TestPostUpdatePropertyFieldsHook(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("post-hook transforms requested attrs and surfaces cleared IDs", func(t *testing.T) {
hook := &testHook{
postUpdateFieldsFn: func(_ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
for _, f := range requested {
if f.Attrs == nil {
f.Attrs = model.StringInterface{}
}
f.Attrs["redacted"] = true
}
ids := make([]string, 0, len(requested))
for _, f := range requested {
ids = append(ids, f.ID)
}
return requested, propagated, ids, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "post-transform-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field.Name = "post-transform-renamed-" + model.NewId()
results, _, cleared, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
require.NoError(t, err)
require.Len(t, results, 1)
assert.Equal(t, true, results[0].Attrs["redacted"], "post-hook attr transform must reach caller")
assert.Equal(t, []string{field.ID}, cleared, "cleared IDs from post-hook must be surfaced")
})
t.Run("post-hook returning wrong-length requested slice is skipped", func(t *testing.T) {
hook := &testHook{
postUpdateFieldsFn: func(_ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
// Drop a field — cardinality guard must reject this transform.
return requested[:0], propagated, nil, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "post-cardinality-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field.Name = "post-cardinality-renamed-" + model.NewId()
results, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
require.NoError(t, err)
assert.Len(t, results, 1, "wrong-length transform must be discarded; original requested must survive")
})
}