// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package identity import ( "bytes" "fmt" "net" "reflect" "testing" "time" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ory/jsonschema/v3" _ "github.com/ory/jsonschema/v3/fileloader" "github.com/ory/kratos/schema" "github.com/ory/kratos/x" ) const ( emailSchemaPath = "file://./stub/extension/verify/email.schema.json" phoneSchemaPath = "file://./stub/extension/verify/phone.schema.json" missingFormatSchemaPath = "file://./stub/extension/verify/missing-format.schema.json" legacyEmailMissingFormatSchemaPath = "file://./stub/extension/verify/legacy-email-missing-format.schema.json" noValidateSchemaPath = "file://./stub/extension/verify/no-validate.schema.json" ) func TestSchemaExtensionVerification(t *testing.T) { net.IP{}.IsPrivate() t.Run("address verification", func(t *testing.T) { iid := x.NewUUID() for _, tc := range []struct { name string schema string expectErr error doc string existing []VerifiableAddress expect []VerifiableAddress }{ { name: "email:must create new address", schema: emailSchemaPath, doc: `{"username":"foo@ory.sh"}`, expect: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, }, }, { name: "email:must create new address because new and existing doesn't match", schema: emailSchemaPath, doc: `{"username":"foo@ory.sh"}`, existing: []VerifiableAddress{ { Value: "bar@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, }, expect: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, }, }, { name: "email:must find existing address in case of match", schema: emailSchemaPath, doc: `{"emails":["baz@ory.sh","foo@ory.sh"]}`, existing: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: true, Status: VerifiableAddressStatusCompleted, Via: AddressTypeEmail, IdentityID: iid, }, { Value: "bar@ory.sh", Verified: true, Status: VerifiableAddressStatusCompleted, Via: AddressTypeEmail, IdentityID: iid, }, }, expect: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: true, Status: VerifiableAddressStatusCompleted, Via: AddressTypeEmail, IdentityID: iid, }, { Value: "baz@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, }, }, { name: "email:must return only one address in case of duplication", schema: emailSchemaPath, doc: `{"emails":["foo@ory.sh","foo@ory.sh","baz@ory.sh"]}`, existing: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: true, Status: VerifiableAddressStatusCompleted, Via: AddressTypeEmail, IdentityID: iid, }, { Value: "bar@ory.sh", Verified: true, Status: VerifiableAddressStatusCompleted, Via: AddressTypeEmail, IdentityID: iid, }, }, expect: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: true, Status: VerifiableAddressStatusCompleted, Via: AddressTypeEmail, IdentityID: iid, }, { Value: "baz@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, }, }, { name: "email:must return error for malformed input", schema: emailSchemaPath, doc: `{"emails":["foo@ory.sh","bar@ory.sh"], "username": "foobar"}`, expectErr: errors.New("I[#/username] S[#/properties/username/format] \"foobar\" is not valid \"email\""), }, { name: "email:must merge email addresses from multiple attributes", schema: emailSchemaPath, doc: `{"emails":["foo@ory.sh","bar@ory.sh","bar@ory.sh"], "username": "foobar@ory.sh"}`, expect: []VerifiableAddress{ { Value: "foo@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, { Value: "bar@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, { Value: "foobar@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: AddressTypeEmail, IdentityID: iid, }, }, }, { name: "phone:must create new address", schema: phoneSchemaPath, doc: `{"username":"+18004444444"}`, expect: []VerifiableAddress{ { Value: "+18004444444", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { name: "phone:must create new address because new and existing doesn't match", schema: phoneSchemaPath, doc: `{"username":"+18004444444"}`, existing: []VerifiableAddress{ { Value: "+442087599036", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, expect: []VerifiableAddress{ { Value: "+18004444444", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { name: "phone:must find existing addresses in case of match", schema: phoneSchemaPath, doc: `{"phones":["+18004444444","+442087599036"]}`, existing: []VerifiableAddress{ { Value: "+442087599036", Verified: true, Status: VerifiableAddressStatusCompleted, Via: ChannelTypeSMS, IdentityID: iid, }, { Value: "+380634872774", Verified: true, Status: VerifiableAddressStatusCompleted, Via: ChannelTypeSMS, IdentityID: iid, }, }, expect: []VerifiableAddress{ { Value: "+442087599036", Verified: true, Status: VerifiableAddressStatusCompleted, Via: ChannelTypeSMS, IdentityID: iid, }, { Value: "+18004444444", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { name: "phone:must return only one address in case of duplication", schema: phoneSchemaPath, doc: `{"phones": ["+18004444444","+18004444444","+442087599036"]}`, existing: []VerifiableAddress{ { Value: "+18004444444", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, { Value: "+380634872774", Verified: true, Status: VerifiableAddressStatusCompleted, Via: ChannelTypeSMS, IdentityID: iid, }, }, expect: []VerifiableAddress{ { Value: "+18004444444", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, { Value: "+442087599036", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { name: "phone:must merge phones from multiple attributes", schema: phoneSchemaPath, doc: `{"phones": ["+18004444444","+18004444444","+442087599036"], "username": "+380634872774"}`, expect: []VerifiableAddress{ { Value: "+18004444444", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, { Value: "+442087599036", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, { Value: "+380634872774", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { name: "phone:must return error for malformed input", schema: phoneSchemaPath, doc: `{"phones":["+18004444444","+18004444444","12112112"], "username": "+380634872774"}`, expectErr: errors.New("I[#/phones/2] S[#/properties/phones/items] validation failed\n I[#/phones/2] S[#/properties/phones/items/format] \"12112112\" is not valid \"tel\"\n I[#/phones/2] S[#/properties/phones/items/format] \"12112112\" can not be normalized for format \"tel\""), }, { name: "missing format returns an error", schema: missingFormatSchemaPath, doc: `{"phone": "+380634872774"}`, expectErr: errors.New("I[#/phone] S[#/properties/phone/format] no format specified. A format is required if verification is enabled. If this was intentional, please set \"format\" to \"no-validate\""), }, { name: "missing format works for email if format is missing", schema: legacyEmailMissingFormatSchemaPath, doc: `{"email": "user@ory.sh"}`, expect: []VerifiableAddress{ { Value: "user@ory.sh", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeEmail, IdentityID: iid, }, }, }, { name: "format: no-validate works", schema: noValidateSchemaPath, doc: `{"phone": "not a phone number"}`, expect: []VerifiableAddress{ { Value: "not a phone number", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { // see https://github.com/ory/kratos/issues/3933 name: "phone:should parse +16453331111", schema: phoneSchemaPath, doc: `{"phones":["+16453331111"]}`, expect: []VerifiableAddress{ { Value: "+16453331111", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, { name: "phone:should normalize +1 (645) 333-1111 to +16453331111", schema: phoneSchemaPath, doc: `{"phones":["+1 (645) 333-1111"]}`, expect: []VerifiableAddress{ { Value: "+16453331111", Verified: false, Status: VerifiableAddressStatusPending, Via: ChannelTypeSMS, IdentityID: iid, }, }, }, } { t.Run(fmt.Sprintf("case=%v", tc.name), func(t *testing.T) { id := &Identity{ID: iid, VerifiableAddresses: tc.existing} c := jsonschema.NewCompiler() runner, err := schema.NewExtensionRunner(t.Context()) require.NoError(t, err) e := NewSchemaExtensionVerification(id, time.Minute) runner.AddRunner(e).Register(c) err = c.MustCompile(t.Context(), tc.schema).Validate(bytes.NewBufferString(tc.doc)) if tc.expectErr != nil { require.EqualError(t, err, tc.expectErr.Error()) return } else { require.NoError(t, err) } require.NoError(t, e.Finish()) addresses := id.VerifiableAddresses require.Len(t, addresses, len(tc.expect)) mustContainAddress(t, tc.expect, addresses) }) } }) } func mustContainAddress(t *testing.T, expected, actual []VerifiableAddress) { for _, act := range actual { var found bool for _, expect := range expected { if reflect.DeepEqual(act, expect) { found = true break } } assert.True(t, found, "%+v not in %+v", act, expected) } } // TestSchemaExtensionVerification_PreservesVerifiedWhenExistingValueIsNotNormalized // is a regression test for the bug where identities created before phone // normalization (#11283) lose their verified status on re-validation because // the persisted VerifiableAddress.Value is in raw form but the extension now // normalizes the trait to E.164 before comparing. // // Invariant we rely on: a persisted phone trait that was verified before must // stay verified after re-validation, even when the persisted // VerifiableAddress.Value is not in E.164 form. func TestSchemaExtensionVerification_PreservesVerifiedWhenExistingValueIsNotNormalized(t *testing.T) { iid := x.NewUUID() // Trait and existing VerifiableAddress both hold the pre-normalization // raw value (contains an extra leading zero from the national trunk // prefix). Before #11283, the extension trimmed only, so this value was // persisted verbatim. After #11283, the same trait normalizes to // "+4917667111638" -- a value that does not match the persisted // VerifiableAddress.Value. rawPhone := "+49 0176 67111638" id := &Identity{ ID: iid, VerifiableAddresses: []VerifiableAddress{{ Value: rawPhone, Verified: true, Status: VerifiableAddressStatusCompleted, Via: ChannelTypeSMS, IdentityID: iid, }}, } c := jsonschema.NewCompiler() runner, err := schema.NewExtensionRunner(t.Context()) require.NoError(t, err) e := NewSchemaExtensionVerification(id, time.Minute) runner.AddRunner(e).Register(c) err = c.MustCompile(t.Context(), phoneSchemaPath). Validate(bytes.NewBufferString(fmt.Sprintf(`{"username":%q}`, rawPhone))) require.NoError(t, err) require.NoError(t, e.Finish()) require.Len(t, id.VerifiableAddresses, 1) assert.True(t, id.VerifiableAddresses[0].Verified, "persisted phone must remain verified after re-validation; got %+v", id.VerifiableAddresses[0]) } func TestMergeVerifiableAddresses(t *testing.T) { for _, tt := range []struct { name string base, overrides, expected []VerifiableAddress }{ { name: "empty base", base: []VerifiableAddress{}, overrides: []VerifiableAddress{{ Value: "override@ory.sh", Via: "email", }}, expected: []VerifiableAddress{}, }, { name: "no overlap", base: []VerifiableAddress{{ Value: "base@ory.sh", Via: "email", }}, overrides: []VerifiableAddress{{ Value: "override@ory.sh", Via: "email", }}, expected: []VerifiableAddress{{ Value: "base@ory.sh", Via: "email", }}, }, { name: "overrides", base: []VerifiableAddress{ {Value: "base@ory.sh", Via: "email"}, {Value: "common-1-is-overwritten@ory.sh", Via: "email"}, {Value: "common-2-is-ignored@ory.sh", Via: "no match"}, }, overrides: []VerifiableAddress{ {Value: "common-1-is-overwritten@ory.sh", Via: "email", Verified: true, Status: VerifiableAddressStatusCompleted}, {Value: "common-2-is-ignored@ory.sh", Via: "wrong via"}, {Value: "override-only-is-ignored@ory.sh", Via: "email"}, }, expected: []VerifiableAddress{ {Value: "base@ory.sh", Via: "email"}, {Value: "common-1-is-overwritten@ory.sh", Via: "email", Verified: true, Status: VerifiableAddressStatusCompleted}, {Value: "common-2-is-ignored@ory.sh", Via: "no match"}, }, }, } { t.Run("case="+tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, merge(tt.base, tt.overrides)) }) } }