Files
upstream-ory-kratos/identity/extension_verification_test.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

541 lines
16 KiB
Go

// 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))
})
}
}