// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package identity_test import ( "context" "encoding/json" "fmt" "net/http" "testing" "time" "github.com/ory/herodot" "github.com/ory/x/configx" "github.com/ory/x/sqlcon" _ "embed" "github.com/gofrs/uuid" "github.com/ory/x/sqlxx" "github.com/ory/kratos/pkg/testhelpers" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ory/kratos/courier" "github.com/ory/kratos/courier/template" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" "github.com/ory/kratos/x" ) //go:embed stub/aal.json var refreshAALStubs []byte func TestManager(t *testing.T) { _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{ config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/", config.ViperKeySelfServiceRegistrationLoginHints: true, config.ViperKeyDefaultIdentitySchemaID: "default", }), configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{ "default": "file://./stub/manager.schema.json", "extension": "file://./stub/extension.schema.json", })), ) t.Run("case=should fail to create because validation fails", func(t *testing.T) { i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i.Traits = identity.Traits(`{"email":"not an email"}`) require.Error(t, reg.IdentityManager().Create(t.Context(), i)) }) newTraits := func(email string, unprotected string) identity.Traits { return identity.Traits(fmt.Sprintf(`{"email":"%[1]s","email_verify":"%[1]s","email_recovery":"%[1]s","email_creds":"%[1]s","unprotected": "%[2]s"}`, email, unprotected)) } checkExtensionFields := func(i *identity.Identity, expected string) func(*testing.T) { return func(t *testing.T) { require.Len(t, i.VerifiableAddresses, 1) assert.EqualValues(t, expected, i.VerifiableAddresses[0].Value) assert.EqualValues(t, identity.AddressTypeEmail, i.VerifiableAddresses[0].Via) require.Len(t, i.RecoveryAddresses, 1) assert.EqualValues(t, expected, i.RecoveryAddresses[0].Value) assert.EqualValues(t, identity.AddressTypeEmail, i.RecoveryAddresses[0].Via) require.NotNil(t, i.Credentials[identity.CredentialsTypePassword]) assert.Equal(t, []string{expected}, i.Credentials[identity.CredentialsTypePassword].Identifiers) } } checkExtensionFieldsForIdentities := func(t *testing.T, expected string, original *identity.Identity) { fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) identities := []identity.Identity{*original, *fromStore} for k := range identities { t.Run(fmt.Sprintf("identity=%d", k), checkExtensionFields(&identities[k], expected)) } } t.Run("method=CreateIdentities", func(t *testing.T) { t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, // By explicitly not setting the identifier, we mimic the behavior of the PATCH endpoint. // This tests a bug we introduced on the PATCH endpoint where the AAL value would not be correct. Identifiers: []string{}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, identity.CredentialsTypeTOTP: { Type: identity.CredentialsTypeTOTP, // By explicitly not setting the identifier, we mimic the behavior of the PATCH endpoint. // This tests a bug we introduced on the PATCH endpoint where the AAL value would not be correct. Identifiers: []string{}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }, } require.NoError(t, reg.IdentityManager().CreateIdentities(t.Context(), []*identity.Identity{original})) fromStore, err := reg.PrivilegedIdentityPool().GetIdentity(t.Context(), original.ID, identity.ExpandNothing) require.NoError(t, err) got, ok := fromStore.InternalAvailableAAL.ToAAL() require.True(t, ok) assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got) }) }) t.Run("method=Create", func(t *testing.T) { t.Run("case=should create identity and track extension fields", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) checkExtensionFieldsForIdentities(t, email, original) got, ok := original.InternalAvailableAAL.ToAAL() require.True(t, ok) assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got) }) t.Run("case=correctly set AAL", func(t *testing.T) { t.Run("case=should set AAL to 0 if no credentials are available", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) got, ok := original.InternalAvailableAAL.ToAAL() require.True(t, ok) assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got) }) t.Run("case=should set AAL to 1 if password is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, } require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) got, ok := original.InternalAvailableAAL.ToAAL() require.True(t, ok) assert.Equal(t, identity.AuthenticatorAssuranceLevel1, got) }) t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, identity.CredentialsTypeTOTP: { Type: identity.CredentialsTypeTOTP, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }, } require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) got, ok := original.InternalAvailableAAL.ToAAL() require.True(t, ok) assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got) }) t.Run("case=should set AAL to 2 if only TOTP is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeTOTP: { Type: identity.CredentialsTypeTOTP, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }, } require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) got, ok := original.InternalAvailableAAL.ToAAL() require.True(t, ok) assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got) }) }) t.Run("case=should expose validation errors with option", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = identity.Traits(`{"email":"not an email"}`) err := reg.IdentityManager().Create(t.Context(), original, identity.ManagerExposeValidationErrorsForInternalTypeAssertion) require.Error(t, err) assert.Contains(t, err.Error(), "\"not an email\" is not valid \"email\"") }) t.Run("case=should not expose validation errors without option", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = identity.Traits(`{"email":"not an email"}`) err := reg.IdentityManager().Create(t.Context(), original) require.Error(t, err) assert.NotContains(t, err.Error(), "\"not an email\" is not valid \"email\"") }) t.Run("case=should correctly hint at the duplicate credential", func(t *testing.T) { createIdentity := func(email string, field string, creds map[identity.CredentialsType]identity.Credentials) *identity.Identity { i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i.Traits = identity.Traits(fmt.Sprintf(`{"%s":"%s"}`, field, email)) i.Credentials = creds return i } t.Run("case=credential identifier duplicate", func(t *testing.T) { t.Run("type=password", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, } first := createIdentity(email, "email_creds", creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, "email_creds", creds) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.EqualValues(t, []string{identity.CredentialsTypePassword.String()}, verr.AvailableCredentials()) assert.Len(t, verr.AvailableOIDCProviders(), 0) assert.Equal(t, verr.IdentifierHint(), email) }) t.Run("type=webauthn", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeWebAuthn: { Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"credentials": [{"is_passwordless":true}]}`), }, } first := createIdentity(email, "email_webauthn", creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, "email_webauthn", nil) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.EqualValues(t, []string{identity.CredentialsTypeWebAuthn.String()}, verr.AvailableCredentials()) assert.Len(t, verr.AvailableOIDCProviders(), 0) assert.Equal(t, verr.IdentifierHint(), email) }) t.Run("type=oidc", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeOIDC: { Type: identity.CredentialsTypeOIDC, // Identifiers in OIDC are not email addresses, but a unique user ID. Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()}, Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`), }, } first := createIdentity(email, "email_creds", creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, "email_creds", creds) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.ElementsMatch(t, []string{"oidc"}, verr.AvailableCredentials()) assert.ElementsMatch(t, []string{"google", "github"}, verr.AvailableOIDCProviders()) // The conflicting identifier is the oidc subject, which is not useful for the user assert.Equal(t, email, verr.IdentifierHint()) }) t.Run("type=password+oidc+webauthn", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, identity.CredentialsTypeOIDC: { Type: identity.CredentialsTypeOIDC, // Identifiers in OIDC are not email addresses, but a unique user ID. Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()}, Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`), }, identity.CredentialsTypeWebAuthn: { Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"credentials": [{"is_passwordless":true}]}`), }, } first := createIdentity(email, "email_creds", creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, "email_creds", creds) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.ElementsMatch(t, []string{"password", "oidc", "webauthn"}, verr.AvailableCredentials()) assert.ElementsMatch(t, []string{"google", "github"}, verr.AvailableOIDCProviders()) assert.Equal(t, email, verr.IdentifierHint()) }) t.Run("type=code", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeCodeAuth: { Type: identity.CredentialsTypeCodeAuth, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{}`), }, } first := createIdentity(email, "email_creds", creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, "email_creds", creds) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.EqualValues(t, []string{identity.CredentialsTypeCodeAuth.String()}, verr.AvailableCredentials()) assert.Len(t, verr.AvailableOIDCProviders(), 0) assert.Equal(t, verr.IdentifierHint(), email) }) }) runAddress := func(t *testing.T, field string) { t.Run("case=password duplicate", func(t *testing.T) { // This test mimics a case where an existing user with email + password exists, and the // new user tries to sign up with a verification email (NOT email + password) that matches // this existing record. Here, the end result is that we want to show the // user: "Sign up with email foo@bar.com and your password instead." email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, } first := createIdentity(email, field, creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, field, nil) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.EqualValues(t, []string{identity.CredentialsTypePassword.String()}, verr.AvailableCredentials()) assert.Len(t, verr.AvailableOIDCProviders(), 0) assert.Equal(t, verr.IdentifierHint(), email) }) t.Run("case=OIDC duplicate", func(t *testing.T) { // This test mimics a case where user signed up using Social Sign In exists, and the // new user tries to sign up with a verification email (NOT email + password) that matches // this existing record (for example by using another social sign in provider. // Here, the end result is that we want to show "Sign in using google instead". email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" creds := map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeOIDC: { Type: identity.CredentialsTypeOIDC, // Identifiers in OIDC are not email addresses, but a unique user ID. Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()}, Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`), }, } first := createIdentity(email, field, creds) require.NoError(t, reg.IdentityManager().Create(t.Context(), first)) second := createIdentity(email, field, nil) err := reg.IdentityManager().Create(t.Context(), second) require.Error(t, err) verr := new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.EqualValues(t, []string{identity.CredentialsTypeOIDC.String()}, verr.AvailableCredentials()) assert.EqualValues(t, verr.AvailableOIDCProviders(), []string{"github", "google"}) assert.Equal(t, verr.IdentifierHint(), email) }) } t.Run("case=verifiable address", func(t *testing.T) { runAddress(t, "email_verify") }) t.Run("case=recovery address", func(t *testing.T) { runAddress(t, "email_recovery") }) }) }) t.Run("method=Update", func(t *testing.T) { t.Run("case=should update identity and update extension fields", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits("baz@ory.sh", "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) original.Traits = newTraits("bar@ory.sh", "") require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)) checkExtensionFieldsForIdentities(t, "bar@ory.sh", original) }) t.Run("case=should reject credentials whose type does not match the map key", func(t *testing.T) { // Safeguards the invariant from any caller that bypasses // Identity.SetCredentials and writes to i.Credentials directly. // The PATCH /admin/identities/{id} handler already enforces // this; the Manager check is defense in depth. email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }) require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) // Bypass SetCredentials and write a malformed entry whose // Type does not match the map key. This is what the patch // handler effectively does when it decodes a JSON Patch // straight into the Credentials map. original.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ Type: "", Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{}`), } err := reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits) require.Error(t, err) var herr *herodot.DefaultError require.True(t, errors.As(err, &herr), "expected *herodot.DefaultError, got %T: %v", err, err) assert.Equal(t, http.StatusBadRequest, herr.CodeField) assert.Equal(t, `credentials.password.type must equal "password", got ""`, herr.Reason()) // Confirm the persisted credential row is byte-identical. reloaded, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) cred := reloaded.Credentials[identity.CredentialsTypePassword] assert.Equal(t, identity.CredentialsTypePassword, cred.Type) assert.Equal(t, []string{email}, cred.Identifiers) assert.JSONEq(t, `{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`, string(cred.Config)) }) t.Run("case=should not touch credentials excluded from the update", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }) require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) fromDB, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) dbCred := fromDB.Credentials[identity.CredentialsTypePassword] // The in-memory password config diverges from the database, as if a // concurrent locked credential-config write had won. The excluded // type must survive the persist. original.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"stale-in-memory"}`), Version: dbCred.Version, }) original.Traits = newTraits(email, "updated") require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits, identity.ManagerWithoutCredentialTypes(identity.CredentialsTypePassword))) reloaded, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) cred := reloaded.Credentials[identity.CredentialsTypePassword] assert.Equal(t, dbCred.ID, cred.ID, "excluded credential must not be recreated") assert.JSONEq(t, string(dbCred.Config), string(cred.Config), "excluded credential config must not change") assert.JSONEq(t, string(newTraits(email, "updated")), string(reloaded.Traits), "traits must still update") // The identity now carries the database state of the excluded type. assert.JSONEq(t, string(dbCred.Config), string(original.Credentials[identity.CredentialsTypePassword].Config)) }) t.Run("case=should set AAL to 1 if password is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, } require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)) assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String) }) t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), }, } require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String) require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)) assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String, "Updating without changes should not change AAL") original.Credentials[identity.CredentialsTypeTOTP] = identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), } require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)) assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.InternalAvailableAAL.String) }) t.Run("case=should set AAL to 2 if only TOTP is set", func(t *testing.T) { email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) original.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeTOTP: { Type: identity.CredentialsTypeTOTP, Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }, } require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)) assert.True(t, original.InternalAvailableAAL.Valid) assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.InternalAvailableAAL.String) }) t.Run("case=should not update protected traits without option", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits("email-update-1@ory.sh", "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) original.Traits = newTraits("email-update-2@ory.sh", "") err := reg.IdentityManager().Update(t.Context(), original) require.Error(t, err) assert.Equal(t, identity.ErrProtectedFieldModified(), errors.Cause(err)) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) // As UpdateTraits takes only the ID as a parameter it cannot update the identity in place. // That is why we only check the identity in the store. checkExtensionFields(fromStore, "email-update-1@ory.sh")(t) }) t.Run("case=should update unprotected traits with multiple credential identifiers", func(t *testing.T) { original := identity.NewIdentity("extension") original.Traits = identity.Traits(`{"email": "email-update-ewisdfuja@ory.sh", "names": ["username1", "username2"], "age": 30}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) assert.Len(t, original.Credentials[identity.CredentialsTypePassword].Identifiers, 3) original.Traits = identity.Traits(`{"email": "email-update-ewisdfuja@ory.sh", "names": ["username1", "username2"], "age": 31}`) require.NoError(t, reg.IdentityManager().Update(t.Context(), original)) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) assert.JSONEq(t, string(original.Traits), string(fromStore.Traits)) }) t.Run("case=should update unprotected traits with verified user", func(t *testing.T) { email := x.NewUUID().String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "initial") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) // mock successful verification process addr := original.VerifiableAddresses[0] addr.Verified = true addr.VerifiedAt = new(sqlxx.NullTime(time.Now().UTC())) require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(t.Context(), &addr)) // reload to properly set the verified address var err error original, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) original.Traits = newTraits(email, "updated") require.NoError(t, reg.IdentityManager().Update(t.Context(), original)) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) assert.JSONEq(t, string(original.Traits), string(fromStore.Traits)) }) t.Run("case=changing recovery address removes it from the store", func(t *testing.T) { originalEmail := x.NewUUID().String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(originalEmail, "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) checkExtensionFields(fromStore, originalEmail)(t) newEmail := x.NewUUID().String() + "@ory.sh" original.Traits = newTraits(newEmail, "") require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)) fromStore, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) checkExtensionFields(fromStore, newEmail)(t) recoveryAddresses, err := reg.PrivilegedIdentityPool().ListRecoveryAddresses(t.Context(), 0, 500) require.NoError(t, err) var foundRecoveryAddress bool for _, a := range recoveryAddresses { assert.NotEqual(t, a.Value, originalEmail) if a.Value == newEmail { foundRecoveryAddress = true } } require.True(t, foundRecoveryAddress) verifiableAddresses, err := reg.PrivilegedIdentityPool().ListVerifiableAddresses(t.Context(), 0, 500) require.NoError(t, err) var foundVerifiableAddress bool for _, a := range verifiableAddresses { assert.NotEqual(t, a.Value, originalEmail) if a.Value == newEmail { foundVerifiableAddress = true } } require.True(t, foundVerifiableAddress) }) }) t.Run("method=CountActiveFirstFactorCredentials", func(t *testing.T) { id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) count, err := reg.IdentityManager().CountActiveFirstFactorCredentials(t.Context(), id) require.NoError(t, err) assert.Equal(t, 0, count) id.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"foo"}, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), } count, err = reg.IdentityManager().CountActiveFirstFactorCredentials(t.Context(), id) require.NoError(t, err) assert.Equal(t, 1, count) }) t.Run("method=CountActiveMultiFactorCredentials", func(t *testing.T) { id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) count, err := reg.IdentityManager().CountActiveMultiFactorCredentials(t.Context(), id) require.NoError(t, err) assert.Equal(t, 0, count) id.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"foo"}, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), } count, err = reg.IdentityManager().CountActiveMultiFactorCredentials(t.Context(), id) require.NoError(t, err) assert.Equal(t, 0, count) id.Credentials[identity.CredentialsTypeWebAuthn] = identity.Credentials{ Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{"foo"}, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`), } count, err = reg.IdentityManager().CountActiveMultiFactorCredentials(t.Context(), id) require.NoError(t, err) assert.Equal(t, 1, count) }) t.Run("method=UpdateTraits", func(t *testing.T) { t.Run("case=should update protected traits with option", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits("email-updatetraits-1@ory.sh", "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) require.NoError(t, reg.IdentityManager().UpdateTraits( t.Context(), original.ID, newTraits("email-updatetraits-2@ory.sh", ""), identity.ManagerAllowWriteProtectedTraits)) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) // As UpdateTraits takes only the ID as a parameter it cannot update the identity in place. // That is why we only check the identity in the store. checkExtensionFields(fromStore, "email-updatetraits-2@ory.sh")(t) }) t.Run("case=should update identity and update extension fields", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = identity.Traits(`{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "foo"}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) // These should all fail because they modify existing keys require.Error(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"not-baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "foo"}`))) require.Error(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"baz@ory.sh","email_verify":"not-baz@ory.sh","email_recovery":"not-baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "foo"}`))) require.Error(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"not-baz@ory.sh","unprotected": "foo"}`))) require.NoError(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "bar"}`))) checkExtensionFieldsForIdentities(t, "baz@ory.sh", original) actual, err := reg.IdentityPool().GetIdentity(t.Context(), original.ID, identity.ExpandNothing) require.NoError(t, err) assert.JSONEq(t, `{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "bar"}`, string(actual.Traits)) }) t.Run("case=should not update protected traits without option", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits("email-updatetraits-1@ory.sh", "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) err := reg.IdentityManager().UpdateTraits( t.Context(), original.ID, newTraits("email-updatetraits-2@ory.sh", "")) require.Error(t, err) assert.Equal(t, identity.ErrProtectedFieldModified(), errors.Cause(err)) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) // As UpdateTraits takes only the ID as a parameter it cannot update the identity in place. // That is why we only check the identity in the store. checkExtensionFields(fromStore, "email-updatetraits-1@ory.sh")(t) }) t.Run("case=should always update updated_at field", func(t *testing.T) { original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits("email-updatetraits-3@ory.sh", "") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) time.Sleep(time.Millisecond) require.NoError(t, reg.IdentityManager().UpdateTraits( t.Context(), original.ID, newTraits("email-updatetraits-4@ory.sh", ""), identity.ManagerAllowWriteProtectedTraits)) updated, err := reg.IdentityPool().GetIdentity(t.Context(), original.ID, identity.ExpandNothing) require.NoError(t, err) assert.NotEqual(t, original.UpdatedAt, updated.UpdatedAt, "UpdatedAt field should be updated") }) t.Run("case=should update unprotected traits with verified user", func(t *testing.T) { email := x.NewUUID().String() + "@ory.sh" original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) original.Traits = newTraits(email, "initial") require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) // mock successful verification so VerifiedAt is non-nil addr := original.VerifiableAddresses[0] addr.Verified = true addr.VerifiedAt = new(sqlxx.NullTime(time.Now().UTC())) require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(t.Context(), &addr)) // UpdateTraits -> SetTraits -> deepcopy.Copy is the path that triggered the bug require.NoError(t, reg.IdentityManager().UpdateTraits( t.Context(), original.ID, newTraits(email, "updated"))) fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID) require.NoError(t, err) assert.JSONEq(t, string(newTraits(email, "updated")), string(fromStore.Traits)) }) }) t.Run("method=RefreshAvailableAAL", func(t *testing.T) { var cases []struct { Credentials []identity.Credentials `json:"credentials"` Description string `json:"description"` Expected string `json:"expected"` } require.NoError(t, json.Unmarshal(refreshAALStubs, &cases)) for k, tc := range cases { t.Run("case="+tc.Description, func(t *testing.T) { email := x.NewUUID().String() + "@ory.sh" id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) id.Traits = identity.Traits(`{"email":"` + email + `"}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), id)) assert.EqualValues(t, identity.NoAuthenticatorAssuranceLevel, id.InternalAvailableAAL.String) for _, c := range tc.Credentials { for k := range c.Identifiers { switch c.Identifiers[k] { case "{email}": c.Identifiers[k] = email case "{id}": c.Identifiers[k] = id.ID.String() } } id.SetCredentials(c.Type, c) } // We use the privileged pool here because we don't want to refresh AAL here but in the code below. require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id)) expand := identity.ExpandNothing if k%2 == 1 { // expand every other test case to test if RefreshAvailableAAL behaves correctly expand = identity.ExpandCredentials } actual, err := reg.IdentityPool().GetIdentity(t.Context(), id.ID, expand) require.NoError(t, err) require.NoError(t, reg.IdentityManager().RefreshAvailableAAL(t.Context(), actual)) assert.NotEmpty(t, actual.Credentials) assert.EqualValues(t, tc.Expected, actual.InternalAvailableAAL.String) }) } }) t.Run("method=ConflictingIdentity", func(t *testing.T) { conflicOnIdentifier := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) conflicOnIdentifier.Traits = identity.Traits(`{"email":"conflict-on-identifier@example.com"}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), conflicOnIdentifier)) conflicOnVerifiableAddress := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) conflicOnVerifiableAddress.Traits = identity.Traits(`{"email":"user-va@example.com", "email_verify":"conflict-on-va@example.com"}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), conflicOnVerifiableAddress)) conflicOnRecoveryAddress := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) conflicOnRecoveryAddress.Traits = identity.Traits(`{"email":"user-ra@example.com", "email_recovery":"conflict-on-ra@example.com"}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), conflicOnRecoveryAddress)) t.Run("case=returns not found if no conflict", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: {Identifiers: []string{"no-conflict@example.com"}}, }, }) assert.ErrorIs(t, err, sqlcon.ErrNoRows()) assert.Nil(t, found) assert.Empty(t, foundConflictAddress) assert.Empty(t, addressType) }) t.Run("case=conflict on identifier", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}}, }, }) require.NoError(t, err) assert.Equal(t, conflicOnIdentifier.ID, found.ID) assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress) assert.EqualValues(t, string(identity.CredentialsTypePassword), addressType) }) t.Run("case=conflict on verifiable address", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ VerifiableAddresses: []identity.VerifiableAddress{{ Value: "conflict-on-va@example.com", Via: "email", }}, }) require.NoError(t, err) assert.Equal(t, conflicOnVerifiableAddress.ID, found.ID) assert.Equal(t, "conflict-on-va@example.com", foundConflictAddress) assert.Equal(t, "email", addressType) }) t.Run("case=conflict on recovery address", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ RecoveryAddresses: []identity.RecoveryAddress{{ Value: "conflict-on-ra@example.com", Via: "email", }}, }) require.NoError(t, err) assert.Equal(t, conflicOnRecoveryAddress.ID, found.ID) assert.Equal(t, "conflict-on-ra@example.com", foundConflictAddress) assert.Equal(t, "email", addressType) }) t.Run("case=identifier match wins over verifiable and recovery address conflicts", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}}, }, VerifiableAddresses: []identity.VerifiableAddress{{ Value: "conflict-on-va@example.com", Via: "email", }}, RecoveryAddresses: []identity.RecoveryAddress{{ Value: "conflict-on-ra@example.com", Via: "email", }}, }) require.NoError(t, err) assert.Equal(t, conflicOnIdentifier.ID, found.ID) assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress) assert.EqualValues(t, string(identity.CredentialsTypePassword), addressType) }) t.Run("case=verifiable address wins over recovery address", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ VerifiableAddresses: []identity.VerifiableAddress{{ Value: "conflict-on-va@example.com", Via: "email", }}, RecoveryAddresses: []identity.RecoveryAddress{{ Value: "conflict-on-ra@example.com", Via: "email", }}, }) require.NoError(t, err) assert.Equal(t, conflicOnVerifiableAddress.ID, found.ID) assert.Equal(t, "conflict-on-va@example.com", foundConflictAddress) assert.Equal(t, "email", addressType) }) t.Run("case=credential type order is deterministic", func(t *testing.T) { oidcSubject := "google:" + x.NewUUID().String() conflictOnOIDC := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) conflictOnOIDC.Traits = identity.Traits(`{"email":"conflict-on-oidc@example.com"}`) conflictOnOIDC.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{oidcSubject}, Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"google"}]}`), }) require.NoError(t, reg.IdentityManager().Create(t.Context(), conflictOnOIDC)) conflictOnPassword := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) conflictOnPassword.Traits = identity.Traits(`{"email":"conflict-on-password@example.com"}`) require.NoError(t, reg.IdentityManager().Create(t.Context(), conflictOnPassword)) // Both credentials match a different identity. The lookup must // check credential types in sorted order, so "oidc" wins over // "password" on every run. for range 10 { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeOIDC: {Identifiers: []string{oidcSubject}}, identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-password@example.com"}}, }, }) require.NoError(t, err) assert.Equal(t, conflictOnOIDC.ID, found.ID) assert.Equal(t, oidcSubject, foundConflictAddress) assert.EqualValues(t, string(identity.CredentialsTypeOIDC), addressType) } }) t.Run("case=second identifier within one credential type matches", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: {Identifiers: []string{"no-conflict@example.com", "conflict-on-identifier@example.com"}}, }, }) require.NoError(t, err) assert.Equal(t, conflicOnIdentifier.ID, found.ID) assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress) assert.EqualValues(t, string(identity.CredentialsTypePassword), addressType) }) t.Run("case=empty identity returns no rows", func(t *testing.T) { found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{}) assert.ErrorIs(t, err, sqlcon.ErrNoRows()) assert.Nil(t, found) assert.Empty(t, foundConflictAddress) assert.Empty(t, addressType) }) t.Run("case=canceled context propagates", func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() _, _, _, err := reg.IdentityManager().ConflictingIdentity(ctx, &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}}, }, }) assert.ErrorIs(t, err, context.Canceled) assert.NotErrorIs(t, err, sqlcon.ErrNoRows()) }) }) } func TestManagerNoDefaultNamedSchema(t *testing.T) { _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{ config.ViperKeyDefaultIdentitySchemaID: "user_v0", config.ViperKeyIdentitySchemas: config.Schemas{ {ID: "user_v0", URL: "file://./stub/manager.schema.json"}, }, config.ViperKeyPublicBaseURL: "https://www.ory.com/", })) t.Run("case=should create identity with default schema", func(t *testing.T) { stateChangedAt := sqlxx.NullTime(time.Now().UTC()) original := &identity.Identity{ SchemaID: "", Traits: []byte(identity.Traits(`{"email":"foo@ory.sh"}`)), State: identity.StateActive, StateChangedAt: &stateChangedAt, } require.NoError(t, reg.IdentityManager().Create(t.Context(), original)) }) } func TestManager_SendVerifiableAddressChangedNotifications(t *testing.T) { _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{ config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/", config.ViperKeyDefaultIdentitySchemaID: "default", }), configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{ "default": "file://./stub/manager.schema.json", })), ) t.Run("case=queues email and sms for supported targets", func(t *testing.T) { ctx := t.Context() i := identity.NewIdentity("default") i.Traits = identity.Traits(`{"email":"new@example.com"}`) require.NoError(t, reg.IdentityManager().Create(ctx, i)) targets := []identity.AddressRef{ {Value: "old@example.com", Via: identity.AddressTypeEmail}, {Value: "+15551234567", Via: identity.AddressTypeSMS}, } require.NoError(t, reg.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, targets, i)) messages, err := reg.CourierPersister().NextMessages(ctx, 10) require.NoError(t, err) require.Len(t, messages, 2) var gotEmail, gotSMS bool for _, m := range messages { switch m.Type { case courier.MessageTypeEmail: assert.Equal(t, "old@example.com", m.Recipient) gotEmail = true case courier.MessageTypeSMS: assert.Equal(t, "+15551234567", m.Recipient) gotSMS = true } } assert.True(t, gotEmail, "expected an email message") assert.True(t, gotSMS, "expected an SMS message") }) t.Run("case=unsupported Via skipped", func(t *testing.T) { ctx := t.Context() i := identity.NewIdentity("default") i.Traits = identity.Traits(`{"email":"skip@example.com"}`) require.NoError(t, reg.IdentityManager().Create(ctx, i)) require.NoError(t, reg.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, []identity.AddressRef{ {Value: "fax:+123", Via: "fax"}, }, i)) messages, err := reg.CourierPersister().NextMessages(ctx, 10) if err == nil { for _, m := range messages { assert.NotEqual(t, "fax:+123", m.Recipient, "fax target must not be queued") } } }) t.Run("case=empty targets is noop", func(t *testing.T) { ctx := t.Context() i := identity.NewIdentity("default") i.Traits = identity.Traits(`{"email":"noop@example.com"}`) require.NoError(t, reg.IdentityManager().Create(ctx, i)) require.NoError(t, reg.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, nil, i)) }) } func TestManager_SendAuthenticatorKeyAddedNotifications(t *testing.T) { _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{ config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/", config.ViperKeyDefaultIdentitySchemaID: "default", }), configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{ "default": "file://./stub/manager.schema.json", })), ) t.Run("case=queues email and sms for supported targets", func(t *testing.T) { ctx := t.Context() i := identity.NewIdentity("default") i.Traits = identity.Traits(`{"email":"key-added@example.com"}`) require.NoError(t, reg.IdentityManager().Create(ctx, i)) targets := []identity.AddressRef{ {Value: "owner@example.com", Via: identity.AddressTypeEmail}, {Value: "+15557654321", Via: identity.AddressTypeSMS}, } require.NoError(t, reg.IdentityManager().SendAuthenticatorKeyAddedNotifications(ctx, targets, i)) messages, err := reg.CourierPersister().NextMessages(ctx, 10) require.NoError(t, err) require.Len(t, messages, 2) var gotEmail, gotSMS bool for _, m := range messages { assert.Equal(t, template.TypeAuthenticatorKeyAdded, m.TemplateType) switch m.Type { case courier.MessageTypeEmail: assert.Equal(t, "owner@example.com", m.Recipient) gotEmail = true case courier.MessageTypeSMS: assert.Equal(t, "+15557654321", m.Recipient) gotSMS = true } } assert.True(t, gotEmail, "expected an email message") assert.True(t, gotSMS, "expected an SMS message") }) t.Run("case=unsupported Via skipped", func(t *testing.T) { ctx := t.Context() i := identity.NewIdentity("default") i.Traits = identity.Traits(`{"email":"skip-key@example.com"}`) require.NoError(t, reg.IdentityManager().Create(ctx, i)) require.NoError(t, reg.IdentityManager().SendAuthenticatorKeyAddedNotifications(ctx, []identity.AddressRef{ {Value: "fax:+123", Via: "fax"}, }, i)) messages, err := reg.CourierPersister().NextMessages(ctx, 10) if err == nil { for _, m := range messages { assert.NotEqual(t, "fax:+123", m.Recipient, "fax target must not be queued") } } }) t.Run("case=empty targets is noop", func(t *testing.T) { ctx := t.Context() i := identity.NewIdentity("default") i.Traits = identity.Traits(`{"email":"noop-key@example.com"}`) require.NoError(t, reg.IdentityManager().Create(ctx, i)) require.NoError(t, reg.IdentityManager().SendAuthenticatorKeyAddedNotifications(ctx, nil, i)) }) }