zitadel/internal/api/scim/resources/user_patch_test.go
Lars 361f7a2edc
fix: relax parsing of SCIM user 'active' flag to improve compatibility (#9296)
# Which Problems Are Solved
- Microsoft Entra invokes the user patch endpoint with `"active":
"True"` / `"active": "False"` when patching a user. This is a well-known
bug in MS Entra (see
[here](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-config-problem-scim-compatibility)),
but the bug fix has not landed yet and/or the feature flag does not
work.

# How the Problems Are Solved
- To ensure compatibility with MS Entra, the parsing of the the boolean
active flag of the scim user is relaxed and accepts strings in any
casing that resolve to `true` or `false` as well as raw boolean values.

# Additional Context
Part of https://github.com/zitadel/zitadel/issues/8140
2025-02-05 16:17:20 +01:00

984 lines
25 KiB
Go

package resources
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/scim/metadata"
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
"github.com/zitadel/zitadel/internal/api/scim/resources/patch"
"github.com/zitadel/zitadel/internal/api/scim/schemas"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/test"
)
func TestOperationCollection_Apply(t *testing.T) {
tests := []struct {
name string
op *patch.Operation
prepare func(user *ScimUser)
want *ScimUser
wantFn func(user *ScimUser)
wantModifications []string
wantErr bool
}{
{
name: "add unknown path",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("fooBar")),
Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`),
},
wantErr: true,
},
{
name: "add without path",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`),
},
want: &ScimUser{
UserName: "hans.muster",
NickName: "hansi",
},
},
{
name: "add without path (sample from rfc)",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Value: json.RawMessage(`
{
"emails":[
{
"value":"babs@jensen.org",
"type":"home",
"primary": true
}
],
"nickname":"Babs"
}`),
},
want: &ScimUser{
NickName: "Babs",
Emails: []*ScimEmail{
{
Value: "jeanie.pendleton@example.com",
Primary: false,
},
{
Value: "babs@jensen.org",
Primary: true,
},
},
},
},
{
name: "add with path value which is nil",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("externalid")),
Value: json.RawMessage(`"externalid-1"`),
},
want: &ScimUser{
ExternalID: "externalid-1",
},
wantModifications: []string{"rep:externalid"},
},
{
name: "add complex attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("name")),
Value: json.RawMessage(`{
"Formatted": "added-formatted",
"FamilyName": "added-family-name",
"GivenName": "added-given-name",
"MiddleName": "added-middle-name",
"HonorificPrefix": "added-honorific-prefix",
"HonorificSuffix": "added-honorific-suffix"
}`),
},
want: &ScimUser{
Name: &ScimUserName{
Formatted: "added-formatted",
FamilyName: "added-family-name",
GivenName: "added-given-name",
MiddleName: "added-middle-name",
HonorificPrefix: "added-honorific-prefix",
HonorificSuffix: "added-honorific-suffix",
},
},
wantModifications: []string{"rep:name"},
},
{
name: "add complex attribute value",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("name.formatted")),
Value: json.RawMessage(`"added-formatted"`),
},
want: &ScimUser{
Name: &ScimUserName{
Formatted: "added-formatted",
},
},
wantModifications: []string{"rep:name.formatted"},
},
{
name: "add single to multi valued empty attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`{
"value": "added-entitlement"
}`),
},
prepare: func(user *ScimUser) {
user.Entitlements = nil
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "added-entitlement",
Primary: false,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add single to multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`{
"value": "added-entitlement"
}`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
{
Value: "added-entitlement",
Primary: false,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add single primary to multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`{
"value": "added-entitlement",
"primary": true
}`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: false,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
{
Value: "added-entitlement",
Primary: true,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add unique valued item in multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`
{
"value": "my-entitlement-1",
"display": "entitlement-1-patched",
"primary": true
}`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "entitlement-1-patched",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add unique valued item and additional item in multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`[
{
"value": "my-entitlement-1",
"display": "entitlement-1-patched",
"primary": true
},
{
"value": "my-entitlement-3",
"display": "entitlement-3",
"primary": false
}
]`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "entitlement-1-patched",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
{
Value: "my-entitlement-3",
Display: "entitlement-3",
Primary: false,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add unique valued items in multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`[
{
"value": "my-entitlement-1",
"display": "entitlement-1-patched",
"primary": true
},
{
"value": "my-entitlement-2",
"display": "entitlement-2-patched",
"primary": false
}
]`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "entitlement-1-patched",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "entitlement-2-patched",
Primary: false,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add multiple to multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(` [
{
"value": "added-entitlement",
"primary": true
},
{
"value": "added-entitlement-2"
}
]`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: false,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
{
Value: "added-entitlement",
Primary: true,
},
{
Value: "added-entitlement-2",
Primary: false,
},
},
},
wantModifications: []string{"add:entitlements"},
},
{
name: "add multiple primaries to multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeAdd,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(` [
{
"value": "added-entitlement",
"primary": true
},
{
"value": "added-entitlement-2",
"primary": true
}
]`),
},
wantErr: true,
},
{
name: "remove unknown path",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("fooBar")),
},
wantErr: true,
},
{
name: "remove without a path",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
},
wantErr: true,
},
{
name: "remove single valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
Path: test.Must(filter.ParsePath("nickname")),
},
wantFn: func(user *ScimUser) {
assert.Equal(t, user.NickName, "")
},
wantModifications: []string{"rem:nickname"},
},
{
name: "remove multi valued attribute",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
Path: test.Must(filter.ParsePath("entitlements")),
},
wantFn: func(user *ScimUser) {
assert.Len(t, user.Entitlements, 0)
},
wantModifications: []string{"rem:entitlements"},
},
{
name: "remove multi valued attribute with filter",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
Path: test.Must(filter.ParsePath(`entitlements[display ew "1"]`)),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
},
wantModifications: []string{"rem:entitlements"},
},
{
name: "remove multi valued attribute with filter matches all",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
Path: test.Must(filter.ParsePath(`entitlements[value pr]`)),
},
wantFn: func(user *ScimUser) {
assert.Len(t, user.Entitlements, 0)
},
wantModifications: []string{"rem:entitlements"},
},
{
name: "remove attribute of multi valued attribute with filter",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
Path: test.Must(filter.ParsePath(`entitlements[display ew "1"].display`)),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "",
Type: "main-entitlement",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
},
wantModifications: []string{"rem:entitlements.display"},
},
{
// this should not fail
// according to the rfc only the replace operation fails without a matching target
name: "remove multi valued attribute with filter and no matches",
op: &patch.Operation{
Operation: patch.OperationTypeRemove,
Path: test.Must(filter.ParsePath(`entitlements[display eq "FOOBAR"]`)),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
},
},
{
name: "replace unknown path",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("fooBar")),
Value: json.RawMessage(`"fooBar"`),
},
wantErr: true,
},
{
name: "replace without path",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`),
},
want: &ScimUser{
UserName: "hans.muster",
NickName: "hansi",
},
},
{
name: "replace single valued attribute with path",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("nickname")),
Value: json.RawMessage(`"fooBar"`),
},
want: &ScimUser{
NickName: "fooBar",
},
},
{
name: "replace with path value which is nil",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("externalid")),
Value: json.RawMessage(`"externalid-1"`),
},
want: &ScimUser{
ExternalID: "externalid-1",
},
wantModifications: []string{"rep:externalid"},
},
{
name: "replace complex attribute",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("name")),
Value: json.RawMessage(`{
"Formatted": "added-formatted",
"FamilyName": "added-family-name",
"GivenName": "added-given-name",
"MiddleName": "added-middle-name",
"HonorificPrefix": "added-honorific-prefix",
"HonorificSuffix": "added-honorific-suffix"
}`),
},
want: &ScimUser{
Name: &ScimUserName{
Formatted: "added-formatted",
FamilyName: "added-family-name",
GivenName: "added-given-name",
MiddleName: "added-middle-name",
HonorificPrefix: "added-honorific-prefix",
HonorificSuffix: "added-honorific-suffix",
},
},
wantModifications: []string{"rep:name"},
},
{
name: "replace complex multi attribute",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`{
"value": "entitlement patched"
}`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "entitlement patched",
},
},
},
wantModifications: []string{"rep:entitlements"},
},
{
name: "replace complex multi attribute with multiple",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`[
{
"value": "entitlement patched"
},
{
"value": "entitlement patched2"
}
]`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "entitlement patched",
},
{
Value: "entitlement patched2",
},
},
},
wantModifications: []string{"rep:entitlements"},
},
{
name: "replace complex multi attribute with multiple primary",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath("entitlements")),
Value: json.RawMessage(`[
{
"value": "entitlement patched",
"primary": true
},
{
"value": "entitlement patched2",
"primary": true
}
]`),
},
wantErr: true,
},
{
name: "replace filter no match",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath(`entitlements[value eq "foobar"]`)),
},
wantErr: true,
},
{
name: "replace filter complex subattribute",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath(`entitlements[value eq "my-entitlement-1"].display`)),
Value: json.RawMessage(`"updated display"`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "updated display",
Type: "main-entitlement",
Primary: true,
},
},
},
wantModifications: []string{"rep:entitlements.display"},
},
{
name: "replace filter complex subattribute primary",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath(`entitlements[value eq "my-entitlement-2"].primary`)),
Value: json.RawMessage(`true`),
},
want: &ScimUser{
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: false,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: true,
},
},
},
wantModifications: []string{"rep:entitlements.primary"},
},
{
name: "replace filter complex subattribute multiple primary",
op: &patch.Operation{
Operation: patch.OperationTypeReplace,
Path: test.Must(filter.ParsePath(`roles[primary ne true].primary`)),
Value: json.RawMessage(`true`),
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := &ScimUser{
ID: "1",
UserName: "username-1",
Name: &ScimUserName{
Formatted: "Dr. Jeanie R. Pendleton III",
FamilyName: "Pendleton",
GivenName: "Jeanie",
MiddleName: "Rebecca",
HonorificPrefix: "Dr.",
HonorificSuffix: "III",
},
DisplayName: "Jeanie Pendleton",
NickName: "Jenny",
Title: "Ms.",
ProfileUrl: test.Must(schemas.ParseHTTPURL("https://example.com/profile.gif")),
PreferredLanguage: language.MustParse("en-US"),
Locale: "en-US",
Timezone: "America/New_York",
Active: schemas.NewRelaxedBool(true),
Emails: []*ScimEmail{
{
Value: "jeanie.pendleton@example.com",
Primary: true,
},
},
PhoneNumbers: []*ScimPhoneNumber{
{
Value: "+1 775-599-5252",
Primary: true,
},
},
Ims: []*ScimIms{
{
Value: "jeeeeeny91",
Type: "icq",
},
},
Addresses: []*ScimAddress{
{
Type: "work",
StreetAddress: "100 Universal City Plaza",
Locality: "Hollywood",
Region: "CA",
PostalCode: "91608",
Country: "USA",
Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA",
Primary: true,
},
},
Photos: []*ScimPhoto{
{
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
Type: "photo",
},
{
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")),
Type: "thumbnail",
},
},
Roles: []*ScimRole{
{
Value: "my-role-1",
Display: "Rolle 1",
Type: "main-role",
Primary: true,
},
{
Value: "my-role-2",
Display: "Rolle 2",
Type: "secondary-role",
Primary: false,
},
{
Value: "my-role-3",
Display: "Rolle 3",
Type: "third-role",
Primary: false,
},
},
Entitlements: []*ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
}
if tt.prepare != nil {
tt.prepare(user)
}
patcher := new(simplePatcher)
operations := patch.OperationCollection{tt.op}
err := operations.Apply(patcher, user)
if tt.wantErr {
require.Error(t, err)
return
} else {
require.NoError(t, err)
}
if !test.PartiallyDeepEqual(tt.want, user) {
t.Errorf("apply() got = %#v, want %#v", user, tt.want)
}
if tt.wantModifications != nil {
assert.EqualValues(t, tt.wantModifications, patcher.modifications)
}
if tt.wantFn != nil {
tt.wantFn(user)
}
})
}
}
func Test_userPatcher_updateMetadata(t *testing.T) {
tests := []struct {
name string
metadataPath []string
metadataChanges map[metadata.Key]*domain.Metadata
metadataKeysToRemove map[metadata.Key]bool
wantErr bool
wantMetadataChanges map[metadata.Key]*domain.Metadata
wantMetadataKeysToRemove map[metadata.Key]bool
}{
{
name: "empty path",
},
{
name: "unknown attribute",
metadataPath: []string{"fooBar"},
},
{
name: "unknown nested attribute",
metadataPath: []string{"foo", "bar"},
},
{
name: "simple attribute",
metadataPath: []string{"title"},
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
metadata.KeyTitle: {
Key: string(metadata.KeyTitle),
Value: []byte("Mr."),
},
},
},
{
name: "simple attribute with previous deletion",
metadataPath: []string{"title"},
metadataKeysToRemove: map[metadata.Key]bool{
metadata.KeyTitle: true,
},
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
metadata.KeyTitle: {
Key: string(metadata.KeyTitle),
Value: []byte("Mr."),
},
},
},
{
name: "nested attribute",
metadataPath: []string{"name", "middlename"},
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
metadata.KeyMiddleName: {
Key: string(metadata.KeyMiddleName),
Value: []byte("middle name"),
},
},
},
{
name: "nested attribute with previous deletion",
metadataPath: []string{"name", "middlename"},
metadataKeysToRemove: map[metadata.Key]bool{
metadata.KeyMiddleName: true,
},
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
metadata.KeyMiddleName: {
Key: string(metadata.KeyMiddleName),
Value: []byte("middle name"),
},
},
},
{
name: "complex object root",
metadataPath: []string{"name"},
wantMetadataChanges: map[metadata.Key]*domain.Metadata{
metadata.KeyMiddleName: {
Key: string(metadata.KeyMiddleName),
Value: []byte("middle name"),
},
metadata.KeyHonorificPrefix: {
Key: string(metadata.KeyHonorificPrefix),
Value: []byte("Adel"),
},
metadata.KeyHonorificSuffix: {
Key: string(metadata.KeyHonorificSuffix),
Value: []byte("III"),
},
},
},
{
name: "delete previous modified attribute",
metadataPath: []string{"locale"},
metadataChanges: map[metadata.Key]*domain.Metadata{
metadata.KeyLocale: {
Key: string(metadata.KeyLocale),
Value: []byte("edited locale"),
},
},
wantMetadataKeysToRemove: map[metadata.Key]bool{
metadata.KeyLocale: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &userPatcher{
ctx: context.Background(),
user: &ScimUser{
Title: "Mr.",
Name: &ScimUserName{
MiddleName: "middle name",
HonorificPrefix: "Adel",
HonorificSuffix: "III",
},
},
metadataChanges: tt.metadataChanges,
metadataKeysToRemove: tt.metadataKeysToRemove,
}
if p.metadataChanges == nil {
p.metadataChanges = make(map[metadata.Key]*domain.Metadata)
}
if p.metadataKeysToRemove == nil {
p.metadataKeysToRemove = make(map[metadata.Key]bool)
}
err := p.updateMetadata(tt.metadataPath)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantMetadataChanges != nil {
if !reflect.DeepEqual(p.metadataChanges, tt.wantMetadataChanges) {
t.Errorf("updateMetadata() got = %#v, want %#v", p.metadataChanges, tt.wantMetadataChanges)
}
}
if tt.metadataKeysToRemove != nil {
if !reflect.DeepEqual(p.metadataKeysToRemove, tt.metadataKeysToRemove) {
t.Errorf("updateMetadata() got = %#v, want %#v", p.metadataKeysToRemove, tt.metadataKeysToRemove)
}
}
})
}
}
type simplePatcher struct {
modifications []string
}
func (s *simplePatcher) FilterEvaluator() *filter.Evaluator {
return filter.NewEvaluator(schemas.IdUser)
}
func (s *simplePatcher) Added(attributePath []string) error {
return s.modified("add", attributePath)
}
func (s *simplePatcher) Replaced(attributePath []string) error {
return s.modified("rep", attributePath)
}
func (s *simplePatcher) Removed(attributePath []string) error {
return s.modified("rem", attributePath)
}
func (s *simplePatcher) modified(op string, attributePath []string) error {
s.modifications = append(s.modifications, op+":"+strings.Join(attributePath, "."))
return nil
}