Files
team/server/channels/app/access_control_masking_test.go
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

1257 lines
48 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"strings"
"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/mock"
"github.com/stretchr/testify/require"
"github.com/hanzoteam/server/server/v8/einterfaces/mocks"
)
func TestExtractFieldName(t *testing.T) {
tests := []struct {
name string
attribute string
expected string
}{
{"standard attribute path", "user.attributes.Program", "Program"},
{"multi-word field", "user.attributes.Clearance Level", "Clearance Level"},
{"no prefix", "Program", ""},
{"partial prefix", "user.attributes.", ""},
{"empty string", "", ""},
{"different prefix", "team.attributes.Program", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractFieldName(tc.attribute)
assert.Equal(t, tc.expected, result)
})
}
}
// Note: tests for field.GetAccessMode() live in model/property_access_test.go,
// where the method is defined (TestPropertyFieldGetAccessMode).
func TestExtractVisibleOptionNames(t *testing.T) {
t.Run("extracts names from valid options", func(t *testing.T) {
field := &model.PropertyField{
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "id1", "name": "Alpha", "color": "red"},
map[string]any{"id": "id2", "name": "Bravo", "color": "blue"},
},
},
}
names := extractVisibleOptionNames(field)
assert.Len(t, names, 2)
assert.Contains(t, names, "Alpha")
assert.Contains(t, names, "Bravo")
})
t.Run("returns empty set for nil attrs", func(t *testing.T) {
field := &model.PropertyField{Attrs: nil}
names := extractVisibleOptionNames(field)
assert.Empty(t, names)
})
t.Run("returns empty set for empty options", func(t *testing.T) {
field := &model.PropertyField{
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{},
},
}
names := extractVisibleOptionNames(field)
assert.Empty(t, names)
})
t.Run("skips options without name field", func(t *testing.T) {
field := &model.PropertyField{
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "id1", "name": "Alpha"},
map[string]any{"id": "id2"}, // no name
},
},
}
names := extractVisibleOptionNames(field)
assert.Len(t, names, 1)
assert.Contains(t, names, "Alpha")
})
t.Run("skips empty name", func(t *testing.T) {
field := &model.PropertyField{
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "id1", "name": ""},
},
},
}
names := extractVisibleOptionNames(field)
assert.Empty(t, names)
})
}
func TestFilterConditionValues(t *testing.T) {
t.Run("multi-value: filters to visible only, sets HasMaskedValues", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{"Alpha", "Bravo", "Charlie"},
ValueType: model.LiteralValue,
AttributeType: "multiselect",
}
visibleNames := map[string]struct{}{"Alpha": {}}
filterConditionValues(condition, visibleNames)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Equal(t, []any{"Alpha"}, values)
assert.True(t, condition.HasMaskedValues)
})
t.Run("multi-value: all visible, no masking", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{"Alpha", "Bravo"},
ValueType: model.LiteralValue,
AttributeType: "multiselect",
}
visibleNames := map[string]struct{}{"Alpha": {}, "Bravo": {}}
filterConditionValues(condition, visibleNames)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Equal(t, []any{"Alpha", "Bravo"}, values)
assert.False(t, condition.HasMaskedValues)
})
t.Run("multi-value: none visible, all masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{"Alpha", "Bravo"},
ValueType: model.LiteralValue,
AttributeType: "multiselect",
}
visibleNames := map[string]struct{}{}
filterConditionValues(condition, visibleNames)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Empty(t, values)
assert.True(t, condition.HasMaskedValues)
})
t.Run("single value: visible, no masking", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Location",
Operator: "==",
Value: "Building 1",
ValueType: model.LiteralValue,
AttributeType: "select",
}
visibleNames := map[string]struct{}{"Building 1": {}}
filterConditionValues(condition, visibleNames)
assert.Equal(t, "Building 1", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("single value: not visible, masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Location",
Operator: "==",
Value: "Building 7",
ValueType: model.LiteralValue,
AttributeType: "select",
}
visibleNames := map[string]struct{}{"Building 1": {}}
filterConditionValues(condition, visibleNames)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("non-string value: skipped without masking", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Active",
Operator: "==",
Value: true,
ValueType: model.LiteralValue,
AttributeType: "text",
}
visibleNames := map[string]struct{}{}
filterConditionValues(condition, visibleNames)
assert.Equal(t, true, condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("slice with non-string elements: non-strings excluded from masking count", func(t *testing.T) {
// A []any with non-string elements should not trigger HasMaskedValues —
// non-strings are not masking candidates, not masked values.
condition := &model.Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{true, 42, "Alpha"},
ValueType: model.LiteralValue,
AttributeType: "multiselect",
}
visibleNames := map[string]struct{}{"Alpha": {}}
filterConditionValues(condition, visibleNames)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Equal(t, []any{"Alpha"}, values)
assert.False(t, condition.HasMaskedValues) // only string "Alpha" counted; it's visible
})
t.Run("nil value: skipped", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Operator: "==",
Value: nil,
ValueType: model.LiteralValue,
}
visibleNames := map[string]struct{}{}
filterConditionValues(condition, visibleNames)
assert.Nil(t, condition.Value)
assert.False(t, condition.HasMaskedValues)
})
}
// TestAttrValueSkip_FilterConditionValuesNotCalled documents why the AttrValue early-return in
// maskConditionValues is necessary: without it, filterConditionValues would treat the attribute
// path string (e.g. "user.attributes.Department") as a literal value and mask it.
func TestAttrValueSkip_FilterConditionValuesNotCalled(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Team",
Operator: "==",
Value: "user.attributes.Department", // attribute path, not a literal
ValueType: model.AttrValue,
}
// If filterConditionValues were called on an AttrValue condition, it would
// incorrectly mask the attribute path since it won't be in the visible set.
filterConditionValues(condition, map[string]struct{}{})
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
// This confirms maskConditionValues MUST return early for AttrValue before
// reaching filterConditionValues. The early-return path in maskConditionValues
// itself is not exercised here; this test only documents why that guard is required.
}
func TestFilterConditionValues_EmptySlice(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{},
ValueType: model.LiteralValue,
AttributeType: "multiselect",
}
visibleNames := map[string]struct{}{"Alpha": {}}
filterConditionValues(condition, visibleNames)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Empty(t, values)
assert.False(t, condition.HasMaskedValues) // nothing was filtered
}
func TestFilterConditionValues_TextFieldMasking(t *testing.T) {
// Text field masking uses the same filterConditionValues function,
// but the visible set comes from the caller's actual text value
// instead of field options.
t.Run("text field with in operator: caller holds matching value", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Clearance",
Operator: "in",
Value: []any{"Top Secret", "Secret", "Confidential"},
ValueType: model.LiteralValue,
AttributeType: "text",
}
// Caller holds "Top Secret" — only this value should be visible
callerTextValues := map[string]struct{}{"Top Secret": {}}
filterConditionValues(condition, callerTextValues)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Equal(t, []any{"Top Secret"}, values)
assert.True(t, condition.HasMaskedValues)
})
t.Run("text field with in operator: caller holds no matching value", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Clearance",
Operator: "in",
Value: []any{"Top Secret", "Secret"},
ValueType: model.LiteralValue,
AttributeType: "text",
}
// Caller holds "Unclassified" — none of the policy values match
callerTextValues := map[string]struct{}{"Unclassified": {}}
filterConditionValues(condition, callerTextValues)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Empty(t, values)
assert.True(t, condition.HasMaskedValues)
})
t.Run("text field with == operator: caller holds matching value", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Clearance",
Operator: "==",
Value: "Top Secret",
ValueType: model.LiteralValue,
AttributeType: "text",
}
callerTextValues := map[string]struct{}{"Top Secret": {}}
filterConditionValues(condition, callerTextValues)
assert.Equal(t, "Top Secret", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("text field with == operator: caller holds different value", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Clearance",
Operator: "==",
Value: "Top Secret",
ValueType: model.LiteralValue,
AttributeType: "text",
}
callerTextValues := map[string]struct{}{"Secret": {}}
filterConditionValues(condition, callerTextValues)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("text field with is not operator: caller holds no value", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Location",
Operator: "!=",
Value: "Building 7",
ValueType: model.LiteralValue,
AttributeType: "text",
}
// Caller has no value for Location — empty visible set
callerTextValues := map[string]struct{}{}
filterConditionValues(condition, callerTextValues)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
}
func TestMaskConditionValues(t *testing.T) {
rctx := request.TestContext(t)
// nil App is safe for every branch that does not reach a.getCallerTextValues
// (i.e., everything except shared_only + text field, which needs a real store).
var a *App
makeField := func(accessMode string, fieldType model.PropertyFieldType, options []any) *model.PropertyField {
attrs := model.StringInterface{model.PropertyAttrsAccessMode: accessMode}
if options != nil {
attrs[model.PropertyFieldAttributeOptions] = options
}
return &model.PropertyField{Type: fieldType, Attrs: attrs}
}
options := []any{
map[string]any{"id": "id1", "name": "Alpha"},
map[string]any{"id": "id2", "name": "Bravo"},
}
t.Run("AttrValue condition: returns immediately, value untouched", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Team",
Value: "user.attributes.Department",
ValueType: model.AttrValue,
}
a.maskConditionValues(rctx, "caller", condition, "", nil)
assert.Equal(t, "user.attributes.Department", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("non-user-attribute path: returns immediately, value untouched", func(t *testing.T) {
condition := &model.Condition{
Attribute: "team.attributes.Program",
Value: "Engineering",
ValueType: model.LiteralValue,
}
a.maskConditionValues(rctx, "caller", condition, "", map[string]*model.PropertyField{})
assert.Equal(t, "Engineering", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("field missing from prefetch map: fail-closed", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Value: "Alpha",
ValueType: model.LiteralValue,
}
a.maskConditionValues(rctx, "caller", condition, "", map[string]*model.PropertyField{})
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("public field: value passes through unchanged", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Program": makeField(model.PropertyAccessModePublic, model.PropertyFieldTypeSelect, options),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Equal(t, "Alpha", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("source_only field: value is nil'd and masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Clearance",
Value: "Top Secret",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Clearance": makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect, options),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("shared_only select: visible option kept, hidden option masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Location",
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Location": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
// "Alpha" is in the field options so it is visible
assert.Equal(t, "Alpha", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("shared_only select: value not in options is masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Location",
Value: "Charlie",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Location": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("shared_only multiselect: visible values kept, hidden values masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Programs",
Value: []any{"Alpha", "Charlie"},
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Programs": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeMultiselect, options),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
values, ok := condition.Value.([]any)
require.True(t, ok)
assert.Equal(t, []any{"Alpha"}, values)
assert.True(t, condition.HasMaskedValues)
})
t.Run("unknown access mode: fail-closed", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes.Program",
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Program": {
Type: model.PropertyFieldTypeSelect,
Attrs: model.StringInterface{model.PropertyAttrsAccessMode: "future_unknown_mode"},
},
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
}
// TestMaskConditionValues_SharedOnlyText covers the shared_only + text-field branch of
// maskConditionValues, which requires a real store to call getCallerTextValues →
// SearchPropertyValues. This branch is intentionally skipped in TestMaskConditionValues
// (which uses a nil App).
//
// A non-CPA V1 group is used so that field creation does not go through the CPA
// access-control layer (which requires a plugin caller for protected/shared_only fields).
// The group ID is passed explicitly to maskConditionValues so store lookups use the same group.
func TestMaskConditionValues_SharedOnlyText(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
rctx := request.TestContext(t)
callerID := model.NewId()
// Register a plain V1 group — no access-control overhead for this group.
group, appErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
Name: "masking_text_test_" + model.NewId(),
Version: model.PropertyGroupVersionV1,
})
require.Nil(t, appErr)
groupID := group.ID
// Create a text field and set shared_only in its Attrs.
// shared_only normally requires protected=true which is plugin-only in the CPA
// group; in this non-CPA group the access-control layer is not applied, so the
// field is written directly.
field := &model.PropertyField{
GroupID: groupID,
Name: "f_" + model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly},
}
createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, err)
// Store "Engineering" as the caller's value for this field.
_, appErr = th.App.CreatePropertyValue(rctx, &model.PropertyValue{
TargetID: callerID,
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: createdField.ID,
Value: json.RawMessage(`"Engineering"`),
})
require.Nil(t, appErr)
fieldsByName := map[string]*model.PropertyField{createdField.Name: createdField}
t.Run("caller's own value passes through", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes." + createdField.Name,
Value: "Engineering",
ValueType: model.LiteralValue,
}
th.App.maskConditionValues(rctx, callerID, condition, groupID, fieldsByName)
assert.Equal(t, "Engineering", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("value the caller does not hold is masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes." + createdField.Name,
Value: "Finance",
ValueType: model.LiteralValue,
}
th.App.maskConditionValues(rctx, callerID, condition, groupID, fieldsByName)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("caller with no stored value is fail-closed", func(t *testing.T) {
condition := &model.Condition{
Attribute: "user.attributes." + createdField.Name,
Value: "Engineering",
ValueType: model.LiteralValue,
}
th.App.maskConditionValues(rctx, model.NewId(), condition, groupID, fieldsByName)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
}
// TestGetMaskedVisualAST_Wiring validates the orchestration inside GetMaskedVisualAST:
// ExpressionToVisualAST is mocked while field lookup and value fetching hit the real store.
//
// The shared_only + text path requires a plugin-owned CPA field and is covered by
// TestMaskConditionValues_SharedOnlyText. This test focuses on:
// - public field: value passes through unchanged (no masking)
// - unknown field: fail-closed (field absent from prefetch map → nil + HasMaskedValues)
func TestGetMaskedVisualAST_Wiring(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
rctx := request.TestContext(t)
cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, cErr)
cpaID := cpaGroup.ID
callerID := model.NewId()
// Create a plain public text field in the CPA group (no access mode = public).
// Non-protected fields are writable by any caller in the CPA group.
fieldName := "f_" + model.NewId()
field := &model.PropertyField{
GroupID: cpaID,
Name: fieldName,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
_, appErr := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, appErr)
t.Run("public field value passes through unchanged", func(t *testing.T) {
visualAST := &model.VisualExpression{
Conditions: []model.Condition{
{Attribute: "user.attributes." + fieldName, Operator: "==", Value: "Engineering", ValueType: model.LiteralValue},
},
}
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(visualAST, nil).Once()
result, err := th.App.GetMaskedVisualAST(rctx, "irrelevant", callerID)
require.Nil(t, err)
require.Len(t, result.Conditions, 1)
assert.Equal(t, "Engineering", result.Conditions[0].Value)
assert.False(t, result.Conditions[0].HasMaskedValues)
mockACS.AssertExpectations(t)
})
t.Run("unknown field name fails closed", func(t *testing.T) {
visualAST := &model.VisualExpression{
Conditions: []model.Condition{
{Attribute: "user.attributes.f_" + model.NewId(), Operator: "==", Value: "SomeValue", ValueType: model.LiteralValue},
},
}
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(visualAST, nil).Once()
result, err := th.App.GetMaskedVisualAST(rctx, "irrelevant", callerID)
require.Nil(t, err)
require.Len(t, result.Conditions, 1)
// Field absent from prefetch map → fail-closed
assert.Nil(t, result.Conditions[0].Value)
assert.True(t, result.Conditions[0].HasMaskedValues)
mockACS.AssertExpectations(t)
})
}
// TestJoinChildExpressions covers the compound-rebuild helper that
// keeps OR / AND / NOT structure intact when
// maskSimulationEvaluationTree walks a compound node bottom-up. The
// helper is pure (no DB / context); these are table-style tests that
// pin the paren wrapping and the dropped-empty-child behavior so a
// future refactor can't silently change either invariant.
func TestJoinChildExpressions(t *testing.T) {
mkChild := func(expr string) model.PolicySimulationEvaluationNode {
return model.PolicySimulationEvaluationNode{Expression: expr}
}
t.Run("no children returns empty", func(t *testing.T) {
assert.Equal(t, "", joinChildExpressions(nil, "&&"))
assert.Equal(t, "", joinChildExpressions([]model.PolicySimulationEvaluationNode{}, "&&"))
})
t.Run("single child wrapped in parens", func(t *testing.T) {
result := joinChildExpressions([]model.PolicySimulationEvaluationNode{mkChild(`user.attributes.x == "a"`)}, "&&")
assert.Equal(t, `(user.attributes.x == "a")`, result)
})
t.Run("multiple children joined with operator", func(t *testing.T) {
children := []model.PolicySimulationEvaluationNode{
mkChild(`user.attributes.x == "a"`),
mkChild(`user.attributes.y == "b"`),
}
assert.Equal(t, `(user.attributes.x == "a") && (user.attributes.y == "b")`, joinChildExpressions(children, "&&"))
assert.Equal(t, `(user.attributes.x == "a") || (user.attributes.y == "b")`, joinChildExpressions(children, "||"))
})
t.Run("empty children dropped so parent has no dangling operator", func(t *testing.T) {
// A child whose leaf masking failed-closed has Expression="";
// the parent must skip it rather than emit "() && (real)" or
// a trailing " && ". Either of those would be invalid CEL and
// would surface to the picker.
children := []model.PolicySimulationEvaluationNode{
mkChild(""),
mkChild(`user.attributes.x == "a"`),
mkChild(""),
}
assert.Equal(t, `(user.attributes.x == "a")`, joinChildExpressions(children, "&&"))
})
t.Run("all children empty returns empty", func(t *testing.T) {
children := []model.PolicySimulationEvaluationNode{mkChild(""), mkChild("")}
assert.Equal(t, "", joinChildExpressions(children, "||"))
})
}
// TestClearEvaluationTreeLiterals pins the fail-closed walker: every
// node's Expression must be wiped and every non-empty ExpectedValue
// must collapse to the masked-token sentinel. The walker is invoked
// from the top-level CPA-group-fetch failure path, so a regression
// here would leak literal values back to the caller through the
// simulator response.
func TestClearEvaluationTreeLiterals(t *testing.T) {
t.Run("nil node is a no-op", func(t *testing.T) {
clearEvaluationTreeLiterals(nil) // must not panic
})
t.Run("leaf with literal: expression cleared, expected and actual sentinel", func(t *testing.T) {
node := &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: `user.attributes.x == "secret"`,
ExpectedValue: "secret",
ActualValue: "secret",
}
clearEvaluationTreeLiterals(node)
assert.Equal(t, "", node.Expression)
assert.Equal(t, maskedTokenValue, node.ExpectedValue)
assert.Equal(t, maskedTokenValue, node.ActualValue,
"fail-closed must also collapse the simulated user's value — it's just as much a literal as the rule's")
})
t.Run("leaf with empty expected/actual: stays empty (no sentinel invented)", func(t *testing.T) {
// A leaf with no recorded literal (e.g. an attribute-vs-
// attribute compare) must NOT have ExpectedValue or
// ActualValue forced to the sentinel — that would invent
// values where the simulator deliberately omitted them.
node := &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: `user.attributes.x == user.attributes.y`,
}
clearEvaluationTreeLiterals(node)
assert.Equal(t, "", node.Expression)
assert.Equal(t, "", node.ExpectedValue)
assert.Equal(t, "", node.ActualValue)
})
t.Run("compound node recurses into children", func(t *testing.T) {
root := &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindAnd,
Expression: `(user.attributes.x == "a") && (user.attributes.y == "b")`,
Children: []model.PolicySimulationEvaluationNode{
{Kind: model.PolicySimulationEvaluationKindCompare, Expression: `user.attributes.x == "a"`, ExpectedValue: "a", ActualValue: "a"},
{Kind: model.PolicySimulationEvaluationKindCompare, Expression: `user.attributes.y == "b"`, ExpectedValue: "b", ActualValue: "z"},
},
}
clearEvaluationTreeLiterals(root)
assert.Equal(t, "", root.Expression)
assert.Equal(t, "", root.Children[0].Expression)
assert.Equal(t, maskedTokenValue, root.Children[0].ExpectedValue)
assert.Equal(t, maskedTokenValue, root.Children[0].ActualValue)
assert.Equal(t, "", root.Children[1].Expression)
assert.Equal(t, maskedTokenValue, root.Children[1].ExpectedValue)
assert.Equal(t, maskedTokenValue, root.Children[1].ActualValue)
})
}
// TestMaskSimulationPolicyLiteralsForCaller_FlagOff and
// _GuardClauses pin the entry-point branches that short-circuit
// without touching the response. Together they prove the function
// is safe to call from the simulate handler regardless of feature-
// flag or input state, so the wiring in
// SimulateAccessControlPolicyForUsers doesn't need to add its own
// gates.
func TestMaskSimulationPolicyLiteralsForCaller_FlagOff(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeValueMasking = false
}).InitBasic(t)
rctx := request.TestContext(t)
resp := &model.PolicySimulationResponse{
Results: []model.PolicySimulationUserResult{{
Decisions: map[string]model.PolicySimulationActionDecision{
"view_channel": {
Blame: []model.PolicySimulationBlame{{
RuleName: "rule1",
Expression: `user.attributes.x == "kept-as-is"`,
EvaluationTree: &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: `user.attributes.x == "kept-as-is"`,
ExpectedValue: "kept-as-is",
},
}},
},
},
}},
}
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
blame := resp.Results[0].Decisions["view_channel"].Blame[0]
assert.Equal(t, `user.attributes.x == "kept-as-is"`, blame.Expression, "flag off must skip masking entirely")
assert.Equal(t, "kept-as-is", blame.EvaluationTree.ExpectedValue, "flag off must skip masking entirely")
}
func TestMaskSimulationPolicyLiteralsForCaller_GuardClauses(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeValueMasking = true
}).InitBasic(t)
rctx := request.TestContext(t)
t.Run("nil response is a no-op", func(t *testing.T) {
// Must not panic and must not touch anything (there's nothing
// to touch). Pinned because the api4 handler dereferences
// resp.Results immediately after this call.
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, nil, model.NewId())
})
t.Run("empty callerID is a no-op", func(t *testing.T) {
// The API layer rejects empty callerID before reaching this function.
// Pinned to ensure MaskSimulationPolicyLiteralsForCaller is safe to call
// with an empty callerID (no panic, no masking applied).
resp := &model.PolicySimulationResponse{
Results: []model.PolicySimulationUserResult{{
Decisions: map[string]model.PolicySimulationActionDecision{
"view_channel": {
Blame: []model.PolicySimulationBlame{{
Expression: `user.attributes.x == "kept"`,
}},
},
},
}},
}
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, "")
assert.Equal(t, `user.attributes.x == "kept"`, resp.Results[0].Decisions["view_channel"].Blame[0].Expression)
})
}
// TestMaskSimulationPolicyLiteralsForCaller_SourceOnly is the end-
// to-end test for the new pass: a real source_only CPA field
// (inserted directly via the store because the App's
// CreatePropertyField hook rejects non-plugin callers from setting
// protected/source_plugin_id) drives the masker against a simulator
// response shaped like the picker output. We mock MaskExpressionForCaller
// — the rest of the masking pipeline (field lookup, simulation tree
// walk, expression backfill) is the real one, because that's the
// layer this test is pinning. Every
// literal-bearing surface (Blame.Expression, the leaf evaluation
// tree's Expression and ExpectedValue, MergedRule.Expression, and
// the merged-rule subtree) must collapse to the "--------" sentinel
// for a caller who isn't the source plugin, regardless of role.
func TestMaskSimulationPolicyLiteralsForCaller_SourceOnly(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeValueMasking = true
}).InitBasic(t)
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
require.True(t, ok)
defer th.App.Srv().SetLicense(nil)
rctx := request.TestContext(t)
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, gErr)
fieldName := celSafeName()
_, sErr := th.Store.PropertyField().Create(&model.PropertyField{
GroupID: cpaGroup.ID,
Name: fieldName,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
},
})
require.NoError(t, sErr)
// Mock MaskExpressionForCaller to return the sentinel for source_only values.
// Tests run without a real PAP wired up; the canonical walker is exercised by
// cel_utils/masking_test.go — here we pin the app-layer wiring end-to-end.
leafExpr := `user.attributes.` + fieldName + ` == "Crimsone One"`
maskedExpr := `user.attributes.` + fieldName + ` == "` + maskedTokenValue + `"`
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
mockACS.On("MaskExpressionForCaller", mock.Anything, mock.Anything, mock.Anything).
Return(maskedExpr, true, nil)
resp := &model.PolicySimulationResponse{
Results: []model.PolicySimulationUserResult{{
User: &model.User{Id: model.NewId()},
Decisions: map[string]model.PolicySimulationActionDecision{
"upload_file_attachment": {
Decision: false,
Blame: []model.PolicySimulationBlame{{
Source: model.PolicySimulationBlameSourceThisRule,
RuleName: "rule1",
Expression: leafExpr,
EvaluationTree: &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: leafExpr,
ExpectedValue: "Crimsone One",
ActualValue: "Crimsone One",
Outcome: model.PolicySimulationEvaluationOutcomeFalse,
Attribute: "user.attributes." + fieldName,
},
MergedRules: []model.PolicySimulationMergedRule{{
Name: "rule1",
Expression: leafExpr,
EvaluationTree: &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: leafExpr,
ExpectedValue: "Crimsone One",
ActualValue: "Crimsone One",
Outcome: model.PolicySimulationEvaluationOutcomeFalse,
Attribute: "user.attributes." + fieldName,
},
}},
}},
},
},
}},
}
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
blame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0]
// Top-level Blame.Expression is re-sourced from the (also-masked)
// evaluation tree root so the OR / NOT shape survives. For a
// single-leaf rule the rebuilt text IS the masked leaf form.
assert.Contains(t, blame.Expression, maskedTokenValue,
"Blame.Expression must carry the masked sentinel for source_only literals")
assert.NotContains(t, blame.Expression, "Crimsone One",
"Blame.Expression must not leak the source_only literal back to the caller")
// Evaluation tree leaf — every surface (Expression, the
// rule-literal ExpectedValue, AND the simulated user's
// ActualValue) must reflect the mask. ActualValue is the user-
// data twin of ExpectedValue: source_only means "nobody but the
// source plugin sees ANY value", so the user's recorded value
// is just as sensitive as the rule literal.
require.NotNil(t, blame.EvaluationTree)
assert.Contains(t, blame.EvaluationTree.Expression, maskedTokenValue)
assert.NotContains(t, blame.EvaluationTree.Expression, "Crimsone One")
assert.Equal(t, maskedTokenValue, blame.EvaluationTree.ExpectedValue,
"leaf ExpectedValue must collapse to the sentinel when the field is source_only")
assert.Equal(t, maskedTokenValue, blame.EvaluationTree.ActualValue,
"leaf ActualValue must collapse to the sentinel when the field is source_only — the picker's 'Actual: X' line is a leak path otherwise")
// MergedRule surfaces are independent of the top-level Blame —
// the picker renders them in a separate "combined evaluation"
// section, so a leak on either path is equally bad.
require.Len(t, blame.MergedRules, 1)
m := blame.MergedRules[0]
assert.Contains(t, m.Expression, maskedTokenValue)
assert.NotContains(t, m.Expression, "Crimsone One")
require.NotNil(t, m.EvaluationTree)
assert.Contains(t, m.EvaluationTree.Expression, maskedTokenValue)
assert.Equal(t, maskedTokenValue, m.EvaluationTree.ExpectedValue)
assert.Equal(t, maskedTokenValue, m.EvaluationTree.ActualValue)
mockACS.AssertExpectations(t)
}
// TestMaskSimulationPolicyLiteralsForCaller_PublicFieldPassesThrough
// proves the masker doesn't over-mask: a plain public CPA field's
// literal value stays visible end-to-end on every surface. Without
// this pin a future refactor could accidentally fail-close on every
// field by treating an empty access_mode as "non-public", silently
// blanking the picker.
func TestMaskSimulationPolicyLiteralsForCaller_PublicFieldPassesThrough(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeValueMasking = true
}).InitBasic(t)
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
require.True(t, ok)
defer th.App.Srv().SetLicense(nil)
rctx := request.TestContext(t)
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, gErr)
fieldName := celSafeName()
_, vAppErr := th.App.CreatePropertyField(rctx, &model.PropertyField{
GroupID: cpaGroup.ID,
Name: fieldName,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, vAppErr)
expr := `user.attributes.` + fieldName + ` == "Engineering"`
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
mockACS.On("MaskExpressionForCaller", mock.Anything, mock.Anything, mock.Anything).
Return(expr, false, nil)
resp := &model.PolicySimulationResponse{
Results: []model.PolicySimulationUserResult{{
Decisions: map[string]model.PolicySimulationActionDecision{
"view_channel": {
Blame: []model.PolicySimulationBlame{{
RuleName: "rule1",
Expression: expr,
EvaluationTree: &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: expr,
ExpectedValue: "Engineering",
ActualValue: "Sales",
Attribute: "user.attributes." + fieldName,
},
}},
},
},
}},
}
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
blame := resp.Results[0].Decisions["view_channel"].Blame[0]
assert.Contains(t, blame.Expression, "Engineering",
"public field literal must pass through unchanged at the top level")
assert.Equal(t, "Engineering", blame.EvaluationTree.ExpectedValue,
"public field leaf ExpectedValue must pass through unchanged")
assert.Equal(t, "Sales", blame.EvaluationTree.ActualValue,
"public field leaf ActualValue must pass through unchanged")
assert.NotContains(t, blame.EvaluationTree.Expression, maskedTokenValue,
"public field leaf Expression must not gain a sentinel")
mockACS.AssertExpectations(t)
}
// TestMaskSimulationPolicyLiteralsForCaller_ActualValueIndependentFromExpected
// pins the most subtle correctness invariant: ExpectedValue and
// ActualValue are checked against the caller's holdings
// independently. For a shared_only text field where the caller
// holds value "A", the rule literal "A" stays visible but a
// simulated user's actual value "B" must mask — because the caller
// is allowed to see "A" (they hold it) but not "B" (they don't).
// Without this pin a regression that masks both values together
// (or neither together) would still pass the source_only and
// public tests but silently leak per-value AVM semantics for the
// shared_only path.
//
// Uses a non-CPA V1 property group for the same reason
// TestMaskConditionValues_SharedOnlyText does: shared_only on the
// CPA group requires a plugin caller for field creation /
// value-write, which is unrelated to what we're pinning here.
func TestMaskSimulationPolicyLiteralsForCaller_ActualValueIndependentFromExpected(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeValueMasking = true
}).InitBasic(t)
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
require.True(t, ok)
defer th.App.Srv().SetLicense(nil)
rctx := request.TestContext(t)
callerID := model.NewId()
group, gAppErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
Name: "sim_mask_actual_test_" + model.NewId(),
Version: model.PropertyGroupVersionV1,
})
require.Nil(t, gAppErr)
groupID := group.ID
field := &model.PropertyField{
GroupID: groupID,
Name: "f_" + model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly},
}
createdField, vAppErr := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, vAppErr)
// Caller holds "A" — that's the rule literal we want to keep
// visible. They do NOT hold "B" — that's the simulated user's
// value that must mask independently.
_, vAppErr = th.App.CreatePropertyValue(rctx, &model.PropertyValue{
TargetID: callerID,
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: createdField.ID,
Value: json.RawMessage(`"A"`),
})
require.Nil(t, vAppErr)
// The simulator pass uses GetPropertyGroup(AccessControlPropertyGroupName)
// to derive the cpaGroupID for its mask context, but the helpers
// we exercise (callerCanSeeFieldValue / maskExpressionWithCache)
// look fields up by name through GetPropertyFieldByName, which
// is group-id-scoped. To make the test drive against our V1
// group we bypass the public entry point and call the
// per-decision helper directly with a mask context built around
// the V1 group. This is the same shortcut existing shared_only
// tests take.
mcResolver, resolverErr := newMaskingResolver(th.App, rctx, callerID)
require.Nil(t, resolverErr)
mc := &simulationMaskContext{
cpaGroupID: groupID,
rctxWithCaller: RequestContextWithCallerID(rctx, callerID),
callerID: callerID,
resolver: mcResolver,
}
// Mock MaskExpressionForCaller to return the original expression: caller holds "A"
// so no literal is hidden, the expression passes through unchanged.
leafExpr := `user.attributes.` + createdField.Name + ` == "A"`
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
mockACS.On("MaskExpressionForCaller", mock.Anything, mock.Anything, mock.Anything).
Return(leafExpr, false, nil)
dec := &model.PolicySimulationActionDecision{
Blame: []model.PolicySimulationBlame{{
RuleName: "rule1",
Expression: leafExpr,
EvaluationTree: &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: leafExpr,
ExpectedValue: "A",
ActualValue: "B",
Attribute: "user.attributes." + createdField.Name,
Outcome: model.PolicySimulationEvaluationOutcomeFalse,
},
}},
}
th.App.maskSimulationDecisionLiterals(dec, mc)
leaf := dec.Blame[0].EvaluationTree
require.NotNil(t, leaf)
// Rule literal "A" is in the caller's held values, so
// ExpectedValue passes through and the leaf Expression stays
// unmasked.
assert.Equal(t, "A", leaf.ExpectedValue,
"shared_only ExpectedValue the caller holds must pass through unchanged")
assert.NotContains(t, leaf.Expression, maskedTokenValue,
"shared_only Expression whose literal the caller holds must stay unmasked")
// Simulated user's value "B" is NOT in the caller's held values,
// so it must mask independently — same field, same access mode,
// different value.
assert.Equal(t, maskedTokenValue, leaf.ActualValue,
"shared_only ActualValue the caller doesn't hold must mask, even when ExpectedValue is visible")
mockACS.AssertExpectations(t)
}
// TestMaskSimulationPolicyLiteralsForCaller_CompoundOrPreserved
// guards the boolean shape of the response. The simulation tree
// walker reconstructs compound nodes bottom-up from already-masked
// leaves, so OR / NOT structure must survive masking. This test seeds
// a two-leaf OR tree against a source_only field, runs the masker,
// and asserts the rebuilt compound still says "||". Without this pin
// a regression would mask the literals correctly but misrepresent the
// rule's logic to the picker.
func TestMaskSimulationPolicyLiteralsForCaller_CompoundOrPreserved(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeValueMasking = true
}).InitBasic(t)
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
require.True(t, ok)
defer th.App.Srv().SetLicense(nil)
rctx := request.TestContext(t)
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, gErr)
fieldName := celSafeName()
_, sErr := th.Store.PropertyField().Create(&model.PropertyField{
GroupID: cpaGroup.ID,
Name: fieldName,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
},
})
require.NoError(t, sErr)
// Each leaf is masked through MaskExpressionForCaller independently.
// Both are source_only so both return the sentinel; we don't care about
// call order — the assertion is on the rebuilt compound preserving "||".
maskedLeaf := `user.attributes.` + fieldName + ` == "` + maskedTokenValue + `"`
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
mockACS.On("MaskExpressionForCaller", mock.Anything, mock.MatchedBy(func(expr string) bool {
return strings.Contains(expr, "Alpha")
}), mock.Anything).Return(maskedLeaf, true, nil)
mockACS.On("MaskExpressionForCaller", mock.Anything, mock.MatchedBy(func(expr string) bool {
return strings.Contains(expr, "Bravo")
}), mock.Anything).Return(maskedLeaf, true, nil)
mkLeaf := func(value string) model.PolicySimulationEvaluationNode {
return model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindCompare,
Expression: `user.attributes.` + fieldName + ` == "` + value + `"`,
ExpectedValue: value,
Attribute: "user.attributes." + fieldName,
}
}
resp := &model.PolicySimulationResponse{
Results: []model.PolicySimulationUserResult{{
Decisions: map[string]model.PolicySimulationActionDecision{
"view_channel": {
Blame: []model.PolicySimulationBlame{{
RuleName: "rule1",
Expression: `(user.attributes.` + fieldName + ` == "Alpha") || (user.attributes.` + fieldName + ` == "Bravo")`,
EvaluationTree: &model.PolicySimulationEvaluationNode{
Kind: model.PolicySimulationEvaluationKindOr,
Children: []model.PolicySimulationEvaluationNode{
mkLeaf("Alpha"),
mkLeaf("Bravo"),
},
},
}},
},
},
}},
}
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
blame := resp.Results[0].Decisions["view_channel"].Blame[0]
require.NotNil(t, blame.EvaluationTree)
assert.Contains(t, blame.EvaluationTree.Expression, "||",
"OR structure must survive masking — collapsing to AND would misrepresent the rule")
assert.NotContains(t, blame.EvaluationTree.Expression, "Alpha")
assert.NotContains(t, blame.EvaluationTree.Expression, "Bravo")
// Top-level Blame.Expression is backfilled from the (masked)
// tree root, so it must inherit the same preserved structure
// and the same absence of literal leaks.
assert.Equal(t, blame.EvaluationTree.Expression, blame.Expression)
mockACS.AssertExpectations(t)
}