// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package test import ( "context" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "sync/atomic" "testing" "time" "github.com/go-faker/faker/v4" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/herodot" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/persistence" idpersistence "github.com/ory/kratos/persistence/sql/identity" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/schema" "github.com/ory/kratos/x" "github.com/ory/pop/v6" "github.com/ory/x/assertx" "github.com/ory/x/crdbx" "github.com/ory/x/pagination/keysetpagination" "github.com/ory/x/popx" "github.com/ory/x/randx" "github.com/ory/x/sqlcon" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" ) // assertContainsValues is a test helper that checks if a slice contains expected values and doesn't contain unexpected values. func assertContainsValues(t *testing.T, actual []string, shouldContain, shouldNotContain []string) { t.Helper() for _, expected := range shouldContain { assert.Contains(t, actual, expected) } for _, notExpected := range shouldNotContain { assert.NotContains(t, actual, notExpected) } } // poolSchemaSet is the identity-schema set the TestPool contract exercises. // // Only ID and RawURL are set on each schema.Schema: the config carries RawURL // (see PoolConfigValues), and SchemaURL() derives the public URL from ID, so // schema.Schema.URL is never read here. type poolSchemaSet struct { expand, def, alt, phoneEmail schema.Schema publicBaseURL *url.URL } func newPoolSchemaSet() poolSchemaSet { return poolSchemaSet{ publicBaseURL: urlx.ParseOrPanic("http://example.com"), expand: schema.Schema{ ID: "expandSchema", RawURL: "file://./stub/expand.schema.json", }, def: schema.Schema{ ID: config.DefaultIdentityTraitsSchemaID, RawURL: "file://./stub/identity.schema.json", }, alt: schema.Schema{ ID: "altSchema", RawURL: "file://./stub/identity-2.schema.json", }, phoneEmail: schema.Schema{ ID: "phoneIdentifier", RawURL: "file://./stub/phone.schema.json", }, } } // PoolConfigValues returns the configuration the TestPool contract requires. // // Prefer passing these to the registry as base config (configx.WithValues) over // attaching them to the context with contextx.WithConfigValues. A context // overlay makes TestConfigProvider.Config rebuild the whole koanf tree — schema // validation and a full re-flatten — on every config read underneath it, and // TestPool reads config from 187 subtests. func PoolConfigValues() map[string]any { s := newPoolSchemaSet() return map[string]any{ config.ViperKeyPublicBaseURL: s.publicBaseURL.String(), // Set explicitly so callers do not also need the suite-wide // testhelpers.WithDefaultIdentitySchema overlay — that overlay replaces // the schema list below with a single "default" entry. config.ViperKeyDefaultIdentitySchemaID: s.def.ID, config.ViperKeyIdentitySchemas: []config.Schema{ {ID: s.alt.ID, URL: s.alt.RawURL}, {ID: s.def.ID, URL: s.def.RawURL}, {ID: s.expand.ID, URL: s.expand.RawURL}, // The multiple_emails schema is only registered so config accepts the // ID; the contract never reads its traits, so it reuses altSchema's file. {ID: "multiple_emails", URL: s.alt.RawURL}, {ID: s.phoneEmail.ID, URL: s.phoneEmail.RawURL}, }, } } // TestPool runs the identity persister contract. The caller must have the values // from PoolConfigValues in scope, ideally baked into the registry's base config. func TestPool(ctx context.Context, p persistence.Persister, m *identity.Manager, dbname string) func(t *testing.T) { return func(t *testing.T) { nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p) s := newPoolSchemaSet() exampleServerURL := s.publicBaseURL expandSchema, defaultSchema, altSchema := s.expand, s.def, s.alt phoneEmailSchema := s.phoneEmail // Fail fast (rather than deep inside case=expand) when the caller did not // put PoolConfigValues into scope: without the schemas registered, // ValidateIdentity returns an opaque schema-lookup error. probe := identity.NewIdentity(expandSchema.ID) probe.Traits = identity.Traits(`{"email":"probe@ory.sh","name":"probe"}`) require.NoError(t, m.ValidateIdentity(ctx, probe, new(identity.ManagerOptions)), "TestPool requires the schemas from identity.PoolConfigValues in scope") t.Run("case=expand", func(t *testing.T) { require.NoError(t, p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE nid = ?", nid).Exec()) t.Cleanup(func() { require.NoError(t, p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE nid = ?", nid).Exec()) }) expected := identity.NewIdentity(expandSchema.ID) expected.Traits = identity.Traits(`{"email":"` + uuid.Must(uuid.NewV4()).String() + "@ory.sh" + `","name":"john doe"}`) require.NoError(t, m.ValidateIdentity(ctx, expected, new(identity.ManagerOptions))) require.NoError(t, p.CreateIdentity(ctx, expected)) require.NoError(t, identity.UpgradeCredentials(expected)) assert.NotEmpty(t, expected.RecoveryAddresses) assert.NotEmpty(t, expected.VerifiableAddresses) assert.NotEmpty(t, expected.Credentials) assert.NotEqual(t, uuid.Nil, expected.RecoveryAddresses[0].ID) assert.NotEqual(t, uuid.Nil, expected.VerifiableAddresses[0].ID) runner := func(t *testing.T, expand sqlxx.Expandables, cb func(*testing.T, *identity.Identity)) { assertion := func(t *testing.T, actual *identity.Identity) { assertx.EqualAsJSONExcept(t, expected, actual, []string{ "verifiable_addresses", "recovery_addresses", "updated_at", "created_at", "credentials", "state_changed_at", // region is hydrated by the multi-region persister from // the row's crdb_region, but the OSS-side `expected` // snapshot is built from the in-memory Identity which // has Region empty when the test does not set it. "region", }) cb(t, actual) } t.Run("find", func(t *testing.T) { actual, err := p.GetIdentity(ctx, expected.ID, expand) require.NoError(t, err) assertion(t, actual) }) t.Run("list/page-pagination", func(t *testing.T) { actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: expand, PagePagination: &x.Page{Page: 0, ItemsPerPage: 10}}) require.NoError(t, err) require.Len(t, actual, 1) assertion(t, &actual[0]) }) t.Run("list/token-pagination", func(t *testing.T) { actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: expand, KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(10)}}) require.NoError(t, err) require.Len(t, actual, 1) require.True(t, next.IsLast()) assertion(t, &actual[0]) }) } t.Run("expand=nothing", func(t *testing.T) { runner(t, identity.ExpandNothing, func(t *testing.T, actual *identity.Identity) { assert.Empty(t, actual.RecoveryAddresses) assert.Empty(t, actual.VerifiableAddresses) assert.Empty(t, actual.Credentials) }) }) t.Run("expand=credentials", func(t *testing.T) { runner(t, identity.ExpandCredentials, func(t *testing.T, actual *identity.Identity) { assert.Empty(t, actual.RecoveryAddresses) assert.Empty(t, actual.VerifiableAddresses) require.Len(t, actual.Credentials, 2) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) }) }) t.Run("expand=recovery address", func(t *testing.T) { runner(t, sqlxx.Expandables{identity.ExpandFieldRecoveryAddresses}, func(t *testing.T, actual *identity.Identity) { assert.Empty(t, actual.Credentials) assert.Empty(t, actual.VerifiableAddresses) require.Len(t, actual.RecoveryAddresses, 1) assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) }) }) t.Run("expand=verification address", func(t *testing.T) { runner(t, sqlxx.Expandables{identity.ExpandFieldVerifiableAddresses}, func(t *testing.T, actual *identity.Identity) { assert.Empty(t, actual.Credentials) assert.Empty(t, actual.RecoveryAddresses) require.Len(t, actual.VerifiableAddresses, 1) assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) }) }) t.Run("expand=default", func(t *testing.T) { runner(t, identity.ExpandDefault, func(t *testing.T, actual *identity.Identity) { assert.Empty(t, actual.Credentials) require.Len(t, actual.RecoveryAddresses, 1) assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) require.Len(t, actual.VerifiableAddresses, 1) assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) }) }) t.Run("expand=everything", func(t *testing.T) { runner(t, identity.ExpandEverything, func(t *testing.T, actual *identity.Identity) { require.Len(t, actual.Credentials, 2) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) require.Len(t, actual.RecoveryAddresses, 1) assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) require.Len(t, actual.VerifiableAddresses, 1) assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) }) }) t.Run("expand=load", func(t *testing.T) { runner(t, identity.ExpandNothing, func(t *testing.T, actual *identity.Identity) { require.NoError(t, p.HydrateIdentityAssociations(ctx, actual, identity.ExpandEverything)) require.Len(t, actual.Credentials, 2) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) require.Len(t, actual.RecoveryAddresses, 1) assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) require.Len(t, actual.VerifiableAddresses, 1) assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) }) }) t.Run("confidential", func(t *testing.T) { // confidential is like expand=all actual, err := p.GetIdentityConfidential(ctx, expected.ID) require.NoError(t, err) assertx.EqualAsJSONExcept(t, expected, actual, []string{ "verifiable_addresses", "recovery_addresses", "updated_at", "created_at", "credentials", "state_changed_at", // region is hydrated by the multi-region persister; the // in-memory expected snapshot has Region empty. "region", }) require.Len(t, actual.Credentials, 2) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) require.Len(t, actual.RecoveryAddresses, 1) assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) require.Len(t, actual.VerifiableAddresses, 1) assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) }) }) t.Run("case=phone number backward compatibility", func(t *testing.T) { // Clean up identities after test to prevent pollution of subsequent tests t.Cleanup(func() { require.NoError(t, p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE nid = ? AND schema_id = ?", nid, phoneEmailSchema.ID).Exec()) }) // Test backward compatibility: simulating the upgrade path for legacy phone credentials nonNormalizedPhone := "+49 176 671 11 638" normalizedPhone := "+4917667111638" t.Run("new identities get normalized credentials from the start", func(t *testing.T) { // Create identity through validation (simulates user registration) newIdentity := identity.NewIdentity(phoneEmailSchema.ID) newIdentity.Traits = identity.Traits(`{"phone":"` + nonNormalizedPhone + `","name":"john doe"}`) require.NoError(t, m.ValidateIdentity(ctx, newIdentity, new(identity.ManagerOptions))) require.NoError(t, p.CreateIdentity(ctx, newIdentity)) // Verify it was created with normalized credentials from the start created, err := p.GetIdentityConfidential(ctx, newIdentity.ID) require.NoError(t, err) // Should have normalized credentials if cred, ok := created.Credentials[identity.CredentialsTypePassword]; ok { require.Contains(t, cred.Identifiers, normalizedPhone, "New identity should have normalized phone") } // Should be findable by normalized phone found, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, normalizedPhone) require.NoError(t, err) require.NotNil(t, found) require.Equal(t, newIdentity.ID, found.ID) require.NotNil(t, cred) }) t.Run("legacy identities are upgraded in memory when loaded", func(t *testing.T) { // Use different phone numbers to avoid conflict with first sub-test legacyNonNormalizedPhone := "+49 30 1234 5678" legacyNormalizedPhone := "+493012345678" // Simulate legacy data: create identity with version 0 credential using raw SQL // This is necessary because CreateIdentity now normalizes identifiers at persistence layer var credTypes []identity.CredentialsTypeTable require.NoError(t, p.GetConnection(ctx).All(&credTypes)) var passwordCredType identity.CredentialsTypeTable for _, ct := range credTypes { if ct.Name == identity.CredentialsTypePassword { passwordCredType = ct break } } legacyID := x.NewUUID() credID := x.NewUUID() identifierID := x.NewUUID() now := time.Now() traits := sqlxx.JSONRawMessage(`{"phone":"` + legacyNonNormalizedPhone + `","name":"jane doe"}`) // Cleanup t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() }) // Insert legacy identity with raw SQL require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", legacyID, nid, phoneEmailSchema.ID, traits, now, now, identity.StateActive, ).Exec()) // Insert version 0 credential require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"hashed_password":"$2a$10$..."}`), 0, ).Exec()) // Insert NON-NORMALIZED identifier only require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", identifierID, legacyID, credID, nid, legacyNonNormalizedPhone, now, now, passwordCredType.ID, ).Exec()) // Load the identity - this triggers UpgradeCredentials in memory loaded, err := p.GetIdentityConfidential(ctx, legacyID) require.NoError(t, err) require.Equal(t, 1, loaded.Credentials[identity.CredentialsTypePassword].Version, "Should be upgraded in memory") // In-memory upgrade adds normalized identifier identifiers := loaded.Credentials[identity.CredentialsTypePassword].Identifiers require.Len(t, identifiers, 1, "Should have single identifier in memory") require.Contains(t, identifiers, legacyNormalizedPhone) // Can find by non-normalized phone (what's in DB) found, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, legacyNonNormalizedPhone) require.NoError(t, err, "Should find by non-normalized phone") require.NotNil(t, found) require.Equal(t, legacyID, found.ID) require.NotNil(t, cred) }) t.Run("post-migration: legacy data normalized in DB", func(t *testing.T) { // Simulates the state after running `kratos migrate normalize-phone-numbers`: // Legacy data has been normalized in the DB, and all lookups should work with any input format. legacyNonNormalizedPhone := "+49 176 12345678" legacyNormalizedPhone := "+4917612345678" var credTypes []identity.CredentialsTypeTable require.NoError(t, p.GetConnection(ctx).All(&credTypes)) var passwordCredType identity.CredentialsTypeTable for _, ct := range credTypes { if ct.Name == identity.CredentialsTypePassword { passwordCredType = ct break } } legacyID := x.NewUUID() credID := x.NewUUID() identifierID := x.NewUUID() now := time.Now() traits := sqlxx.JSONRawMessage(`{"phone":"` + legacyNonNormalizedPhone + `","name":"migrated user"}`) t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() }) // Step 1: Insert legacy identity with non-normalized phone (pre-migration state) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", legacyID, nid, phoneEmailSchema.ID, traits, now, now, identity.StateActive, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"hashed_password":"$2a$10$..."}`), 0, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", identifierID, legacyID, credID, nid, legacyNonNormalizedPhone, now, now, passwordCredType.ID, ).Exec()) // Step 2: Simulate migration — normalize the identifier in DB (what the CLI tool does) require.NoError(t, p.GetConnection(ctx).RawQuery( "UPDATE identity_credential_identifiers SET identifier = ? WHERE id = ?", legacyNormalizedPhone, identifierID, ).Exec()) // Step 3: Verify lookups work with both formats after migration // Find by E.164 (normalized) — should match the migrated DB value found, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, legacyNormalizedPhone) require.NoError(t, err, "should find migrated identity by E.164 phone") require.Equal(t, legacyID, found.ID) require.NotNil(t, cred) // Find by original non-normalized format — IN query normalizes input, matches migrated DB value found2, cred2, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, legacyNonNormalizedPhone) require.NoError(t, err, "should find migrated identity by non-normalized phone (IN query normalizes)") require.Equal(t, legacyID, found2.ID) require.NotNil(t, cred2) // Find by yet another format variant — also normalizes to same E.164 found3, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, "+49 17612345678") require.NoError(t, err, "should find migrated identity by different format variant") require.Equal(t, legacyID, found3.ID) // Also test FindIdentityByCredentialIdentifier found4, err := p.FindIdentityByCredentialIdentifier(ctx, legacyNormalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find migrated identity by E.164 via FindIdentityByCredentialIdentifier") require.Equal(t, legacyID, found4.ID) found5, err := p.FindIdentityByCredentialIdentifier(ctx, legacyNonNormalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find migrated identity by non-normalized via FindIdentityByCredentialIdentifier") require.Equal(t, legacyID, found5.ID) }) }) var createdIDs []uuid.UUID passwordIdentity := func(schemaID string, credentialsID string) *identity.Identity { i := identity.NewIdentity(schemaID) i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{credentialsID}, Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`), }) return i } oidcIdentity := func(schemaID string, credentialsID string) *identity.Identity { i := identity.NewIdentity(schemaID) i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{credentialsID}, Config: sqlxx.JSONRawMessage(`{}`), }) return i } assertEqual := func(t *testing.T, expected, actual *identity.Identity) { assert.Empty(t, actual.Credentials) require.Equal(t, expected.Traits, actual.Traits) require.Equal(t, expected.ID, actual.ID) } t.Run("case=should create and set missing ID", func(t *testing.T) { i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(`{}`), }) i.ID = uuid.Nil require.NoError(t, p.CreateIdentity(ctx, i)) assert.NotEqual(t, uuid.Nil, i.ID) assert.Equal(t, nid, i.NID) createdIDs = append(createdIDs, i.ID) count, err := p.CountIdentities(ctx) require.NoError(t, err) assert.EqualValues(t, int64(1), count) t.Run("different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) count, err := p.CountIdentities(ctx) require.NoError(t, err) assert.EqualValues(t, int64(0), count) }) }) t.Run("case=create with default values", func(t *testing.T) { expected := passwordIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) require.NoError(t, err) assert.Equal(t, expected.ID, actual.ID) assert.Equal(t, config.DefaultIdentityTraitsSchemaID, actual.SchemaID) assert.Equal(t, defaultSchema.SchemaURL(exampleServerURL).String(), actual.SchemaURL) assertEqual(t, expected, actual) count, err := p.CountIdentities(ctx) require.NoError(t, err) assert.EqualValues(t, 2, count) t.Run("different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) require.ErrorIs(t, err, sqlcon.ErrNoRows()) count, err := p.CountIdentities(ctx) require.NoError(t, err) assert.EqualValues(t, int64(0), count) }) }) t.Run("case=should set external ID", func(t *testing.T) { i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(`{}`), }) i.ID = uuid.Nil externalID := sqlxx.NullString("external-id-" + randx.MustString(10, randx.AlphaNum)) i.ExternalID = externalID require.NoError(t, p.CreateIdentity(ctx, i)) assert.NotEqual(t, uuid.Nil, i.ID) assert.Equal(t, nid, i.NID) assert.Equal(t, externalID, i.ExternalID) createdIDs = append(createdIDs, i.ID) t.Run("find by external ID", func(t *testing.T) { i2, err := p.FindIdentityByExternalID(ctx, externalID.String(), identity.ExpandEverything) require.NoError(t, err) assert.Equal(t, i.ID, i2.ID) }) t.Run("must be unique", func(t *testing.T) { i2 := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i2.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(`{}`), }) i2.ExternalID = externalID err := new(herodot.DefaultError) require.ErrorAs(t, p.CreateIdentity(ctx, i2), &err) assert.Equal(t, http.StatusConflict, err.CodeField) }) }) t.Run("case=create with null AAL", func(t *testing.T) { expected := passwordIdentity("", "id-"+uuid.Must(uuid.NewV4()).String()) expected.InternalAvailableAAL.Valid = false require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) require.NoError(t, err) assert.False(t, actual.InternalAvailableAAL.Valid) }) t.Run("suite=create multiple identities", func(t *testing.T) { t.Run("create multiple identities", func(t *testing.T) { identities := make([]*identity.Identity, 100) for i := range identities { identities[i] = NewTestIdentity(4, "persister-create-multiple", i) } require.NoError(t, p.CreateIdentities(ctx, identities)) createdAt := time.Now().UTC() for _, id := range identities { idFromDB, err := p.GetIdentity(ctx, id.ID, identity.ExpandEverything) require.NoError(t, err) credFromDB := idFromDB.Credentials[identity.CredentialsTypePassword] assert.Equal(t, id.ID, idFromDB.ID) assert.Equal(t, id.SchemaID, idFromDB.SchemaID) assert.Equal(t, id.SchemaURL, idFromDB.SchemaURL) assert.Equal(t, id.State, idFromDB.State) // We test that the values are plausible in the handler test already. assert.Equal(t, len(id.VerifiableAddresses), len(idFromDB.VerifiableAddresses)) assert.Equal(t, len(id.RecoveryAddresses), len(idFromDB.RecoveryAddresses)) assert.Equal(t, id.Credentials["password"].Identifiers, credFromDB.Identifiers) assert.WithinDuration(t, createdAt, credFromDB.CreatedAt, time.Minute) assert.WithinDuration(t, createdAt, credFromDB.UpdatedAt, time.Minute) // because of mysql precision assert.WithinDuration(t, id.CreatedAt, idFromDB.CreatedAt, time.Second) assert.WithinDuration(t, id.UpdatedAt, idFromDB.UpdatedAt, time.Second) require.NoError(t, p.DeleteIdentity(ctx, id.ID)) } }) t.Run("create exactly the non-conflicting ones", func(t *testing.T) { identities := make([]*identity.Identity, 100) for i := range identities { identities[i] = NewTestIdentity(4, "persister-create-multiple-2", i%60) } err := p.CreateIdentities(ctx, identities) if dbname == "mysql" { // partial inserts are not supported on mysql assert.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) return } createdAt := time.Now().UTC() errWithCtx := new(identity.CreateIdentitiesError) require.ErrorAsf(t, err, &errWithCtx, "%#v", err) for _, id := range identities[:60] { require.NotZero(t, id.ID) idFromDB, err := p.GetIdentity(ctx, id.ID, identity.ExpandEverything) require.NoError(t, err) credFromDB := idFromDB.Credentials[identity.CredentialsTypePassword] assert.Equal(t, id.ID, idFromDB.ID) assert.Equal(t, id.SchemaID, idFromDB.SchemaID) assert.Equal(t, id.SchemaURL, idFromDB.SchemaURL) assert.Equal(t, id.State, idFromDB.State) // We test that the values are plausible in the handler test already. assert.Equal(t, len(id.VerifiableAddresses), len(idFromDB.VerifiableAddresses)) assert.Equal(t, len(id.RecoveryAddresses), len(idFromDB.RecoveryAddresses)) assert.Equal(t, id.Credentials["password"].Identifiers, credFromDB.Identifiers) assert.WithinDuration(t, createdAt, credFromDB.CreatedAt, time.Minute) assert.WithinDuration(t, createdAt, credFromDB.UpdatedAt, time.Minute) // because of mysql precision assert.WithinDuration(t, id.CreatedAt, idFromDB.CreatedAt, time.Second) assert.WithinDuration(t, id.UpdatedAt, idFromDB.UpdatedAt, time.Second) require.NoError(t, p.DeleteIdentity(ctx, id.ID)) } for _, id := range identities[60:] { failed := errWithCtx.Find(id) assert.NotNil(t, failed) } }) t.Run("case=conflict only on credential identifier marks the identity as failed", func(t *testing.T) { // Regression test: OIDC identities carry no verifiable or recovery // addresses, so a duplicate conflicts solely on the credential // identifier. CreateIdentities must map that conflict back to the // owning identity, mark it failed, and not leave a partially // persisted identity behind. if dbname == "mysql" { // Partial inserts are not supported on mysql. t.Skip() } oidcID := randx.MustString(16, randx.AlphaLowerNum) initial := oidcIdentity("", oidcID) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) unique := oidcIdentity("", randx.MustString(16, randx.AlphaLowerNum)) conflicting := oidcIdentity("", oidcID) batch := []*identity.Identity{unique, conflicting} err := p.CreateIdentities(ctx, batch) errWithCtx := new(identity.CreateIdentitiesError) require.ErrorAsf(t, err, &errWithCtx, "%#v", err) // The conflicting identity must be reported as failed and must not // exist in the database. require.NotNil(t, errWithCtx.Find(conflicting)) _, err = p.GetIdentity(ctx, conflicting.ID, identity.ExpandNothing) require.Error(t, err) // The non-conflicting identity must succeed and be persisted. require.Nil(t, errWithCtx.Find(unique)) _, err = p.GetIdentity(ctx, unique.ID, identity.ExpandNothing) require.NoError(t, err) createdIDs = append(createdIDs, unique.ID) }) t.Run("case=transaction retry re-runs the batch create with the ids from the first attempt", func(t *testing.T) { // A transaction retry discards the first attempt's writes and runs // the closure passed to the Transaction helper again. The ids // assigned during the first attempt (generated by the database on // CockroachDB, by Go elsewhere) stay on the in-memory structs, so // the second attempt inserts the same identities with pre-assigned // ids. The result must be indistinguishable from a first-try // success, in particular the child-table rows must be linked to // the right identities. // // CreateIdentities joins a surrounding transaction, so the retry // re-runs it as a whole — exactly what happens when the driver // retries the transaction of a plain CreateIdentities call. identities := make([]*identity.Identity, 4) for i := range identities { identities[i] = NewTestIdentity(2, "retry-batch", i) } attempt := 0 idsSeen := make(map[int][]uuid.UUID) require.NoError(t, p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error { attempt++ if err := p.CreateIdentities(ctx, identities); err != nil { return err } for _, ident := range identities { if !assert.NotEqual(t, uuid.Nil, ident.ID) { return errors.New("identity id was not assigned") } idsSeen[attempt] = append(idsSeen[attempt], ident.ID) } if attempt == 1 { // Force a genuine retryable error so the Transaction // helper rolls this attempt back and re-runs the closure. if dbname == "cockroach" { return tx.RawQuery("SELECT crdb_internal.force_retry('1h'::INTERVAL)").Exec() } return sqlcon.ErrConcurrentUpdate() } return nil })) require.Equal(t, 2, attempt, "the transaction closure must have run twice") assert.Equal(t, idsSeen[1], idsSeen[2], "the retry must reuse the ids assigned on the first attempt") for _, ident := range identities { fromDB, err := p.GetIdentity(ctx, ident.ID, identity.ExpandEverything) require.NoError(t, err) assert.Equal(t, ident.ID, fromDB.ID) assert.Equal(t, ident.Credentials[identity.CredentialsTypePassword].Identifiers, fromDB.Credentials[identity.CredentialsTypePassword].Identifiers, "the credentials must belong to the identity that carried them in the input batch") assert.Len(t, fromDB.VerifiableAddresses, len(ident.VerifiableAddresses)) assert.Len(t, fromDB.RecoveryAddresses, len(ident.RecoveryAddresses)) // Clean up so the later list assertions, which expect every // remaining identity to be tracked in createdIDs and to fit // in a single page, are unaffected. require.NoError(t, p.DeleteIdentity(ctx, ident.ID)) } }) t.Run("case=transaction retry keeps partial-conflict semantics", func(t *testing.T) { if dbname == "mysql" { // Partial inserts are not supported on mysql. t.Skip() } // Same retry scenario, but on the partial-inserts path: one // identity in the batch conflicts on external_id with a // pre-existing identity. The retry re-runs the batch with the ids // assigned on the first attempt; the conflict must still be // attributed to the same input identity and the non-conflicting // one must be persisted. existing := NewTestIdentity(1, "retry-partial-existing", 0) existing.ExternalID = sqlxx.NullString("retry-partial-ext-0") require.NoError(t, p.CreateIdentity(ctx, existing)) unique := NewTestIdentity(1, "retry-partial-unique", 1) conflicting := NewTestIdentity(1, "retry-partial-conflict", 2) conflicting.ExternalID = existing.ExternalID batch := []*identity.Identity{unique, conflicting} attempt := 0 var createErr error require.NoError(t, p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error { attempt++ createErr = p.CreateIdentities(ctx, batch) if _, ok := errors.AsType[*identity.CreateIdentitiesError](createErr); createErr != nil && !ok { return createErr } if attempt == 1 { if dbname == "cockroach" { return tx.RawQuery("SELECT crdb_internal.force_retry('1h'::INTERVAL)").Exec() } return sqlcon.ErrConcurrentUpdate() } return nil })) require.Equal(t, 2, attempt, "the transaction closure must have run twice") errWithCtx := new(identity.CreateIdentitiesError) require.ErrorAsf(t, createErr, &errWithCtx, "%#v", createErr) assert.NotNil(t, errWithCtx.Find(conflicting), "the conflict must be attributed to the conflicting input identity") assert.Nil(t, errWithCtx.Find(unique)) fromDB, err := p.GetIdentity(ctx, unique.ID, identity.ExpandNothing) require.NoError(t, err) assert.Equal(t, unique.ID, fromDB.ID) _, err = p.GetIdentity(ctx, conflicting.ID, identity.ExpandNothing) require.Error(t, err, "the conflicting identity must not be persisted") // Clean up so the later list assertions, which expect every // remaining identity to be tracked in createdIDs and to fit in // a single page, are unaffected. require.NoError(t, p.DeleteIdentity(ctx, unique.ID)) require.NoError(t, p.DeleteIdentity(ctx, existing.ID)) }) }) t.Run("case=external_id conflict in batch create returns partial error", func(t *testing.T) { // Regression test for https://github.com/ory-corp/cloud/issues/10580 // When all identities in a batch conflict on external_id, CreateIdentities // should return a CreateIdentitiesError, not a raw SQL error. // On Postgres, the bug causes: ERROR: syntax error at or near ")" (SQLSTATE 42601) // because DeleteIdentities is called with an empty ID slice, generating IN (). // Step 1: Create two identities with external_ids. first := make([]*identity.Identity, 2) for i := range first { first[i] = NewTestIdentity(1, "ext-id-conflict-first", i) first[i].ExternalID = sqlxx.NullString(fmt.Sprintf("ext-conflict-pool-%d", i)) } require.NoError(t, p.CreateIdentities(ctx, first)) for _, id := range first { createdIDs = append(createdIDs, id.ID) } // Step 2: Create new identities with different traits but same external_ids. second := make([]*identity.Identity, 2) for i := range second { second[i] = NewTestIdentity(1, "ext-id-conflict-second", i) second[i].ExternalID = sqlxx.NullString(fmt.Sprintf("ext-conflict-pool-%d", i)) } err := p.CreateIdentities(ctx, second) if dbname == "mysql" { assert.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) return } errWithCtx := new(identity.CreateIdentitiesError) require.ErrorAsf(t, err, &errWithCtx, "expected CreateIdentitiesError, got: %#v", err) for _, id := range second { assert.NotNil(t, errWithCtx.Find(id), "expected identity %s to be in the error", id.ID) } }) t.Run("case=should error when the identity ID does not exist", func(t *testing.T) { _, err := p.GetIdentity(ctx, uuid.UUID{}, identity.ExpandNothing) require.Error(t, err) _, err = p.GetIdentity(ctx, x.NewUUID(), identity.ExpandNothing) require.Error(t, err) _, err = p.GetIdentityConfidential(ctx, x.NewUUID()) require.Error(t, err) }) t.Run("case=run migrations when fetching credentials", func(t *testing.T) { expected := func(schemaID string, credentialsID string) *identity.Identity { i := identity.NewIdentity(schemaID) i.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{credentialsID}, Config: sqlxx.JSONRawMessage(`{"credentials":[{}]}`), }) return i }(altSchema.ID, "webauthn") require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, err := p.GetIdentityConfidential(ctx, expected.ID) require.NoError(t, err) c := actual.GetCredentialsOr(identity.CredentialsTypeWebAuthn, &identity.Credentials{}) assert.True(t, gjson.GetBytes(c.Config, "credentials.0.is_passwordless").Exists()) assert.Equal(t, base64.StdEncoding.EncodeToString(expected.ID[:]), gjson.GetBytes(c.Config, "user_handle").String()) }) t.Run("case=create and keep set values", func(t *testing.T) { expected := passwordIdentity(altSchema.ID, x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) require.NoError(t, err) assert.Equal(t, altSchema.ID, actual.SchemaID) assert.Equal(t, altSchema.SchemaURL(exampleServerURL).String(), actual.SchemaURL) assertEqual(t, expected, actual) actual, err = p.GetIdentityConfidential(ctx, expected.ID) require.NoError(t, err) require.Equal(t, expected.Traits, actual.Traits) require.Equal(t, expected.ID, actual.ID) assert.NotEmpty(t, actual.Credentials) assert.NotEmpty(t, expected.Credentials) for m, expected := range expected.Credentials { assert.Equal(t, expected.ID, actual.Credentials[m].ID) assert.JSONEq(t, string(expected.Config), string(actual.Credentials[m].Config)) assert.Equal(t, expected.Identifiers, actual.Credentials[m].Identifiers) assert.Equal(t, expected.Type, actual.Credentials[m].Type) } t.Run("different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.ErrorIs(t, err, sqlcon.ErrNoRows()) _, err = p.GetIdentityConfidential(ctx, expected.ID) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=fail on duplicate credential identifiers if type is password", func(t *testing.T) { email := randx.MustString(16, randx.AlphaLowerNum) + "@bar.com" initial := passwordIdentity("", email) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) for _, transform := range []func(string) string{ strings.ToLower, func(s string) string { return s[:1] + strings.ToUpper(s[1:2]) + s[2:] }, strings.ToUpper, func(s string) string { left, right, _ := strings.Cut(s, "@"); return left + "@" + strings.Title(right) }, // Invisible Unicode characters (category Cf) must not let an // attacker register a second visually-identical identifier. func(s string) string { return "\u200B" + s }, // zero-width space prefix func(s string) string { return s[:1] + "\u200C" + s[1:] }, func(s string) string { return s[:1] + "\u200D" + s[1:] }, func(s string) string { return s[:1] + "\u00AD" + s[1:] }, func(s string) string { return s[:1] + "\uFEFF" + s[1:] }, func(s string) string { return s[:1] + "\u2060" + s[1:] }, func(s string) string { return s[:1] + "\u180E" + s[1:] }, // NFKC compatibility decomposition must collapse fullwidth // lookalikes into ASCII. func(s string) string { left, right, _ := strings.Cut(s, "@") out := make([]rune, 0, len(left)) for _, r := range left { if r >= 'a' && r <= 'z' { out = append(out, 0xFF41+(r-'a')) continue } if r >= '0' && r <= '9' { out = append(out, 0xFF10+(r-'0')) continue } out = append(out, r) } return string(out) + "@" + right }, } { ids := transform(email) expected := passwordIdentity("", ids) err := p.CreateIdentity(ctx, expected) require.ErrorIs(t, err, sqlcon.ErrUniqueViolation(), "%+v", err) _, err = p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.Error(t, err) t.Run("succeeds on different network/id="+ids, func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) expected := passwordIdentity("", ids) err := p.CreateIdentity(ctx, expected) require.NoError(t, err) _, err = p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.NoError(t, err) }) } }) t.Run("case=fail on duplicate credential identifiers if type is oidc", func(t *testing.T) { oidcID := randx.MustString(16, randx.AlphaLowerNum) initial := oidcIdentity("", oidcID) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) expected := oidcIdentity("", oidcID) require.Error(t, p.CreateIdentity(ctx, expected)) _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.Error(t, err) second := oidcIdentity("", strings.ToUpper(oidcID)) require.NoError(t, p.CreateIdentity(ctx, second), "should work because oidc is not case-sensitive") createdIDs = append(createdIDs, second.ID) t.Run("succeeds on different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) expected := oidcIdentity("", oidcID) require.NoError(t, p.CreateIdentity(ctx, expected)) _, err = p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.NoError(t, err) }) }) t.Run("case=create with invalid traits data", func(t *testing.T) { expected := oidcIdentity("", x.NewUUID().String()) expected.Traits = identity.Traits(`{"bar":123}`) // bar should be a string err := p.CreateIdentity(ctx, expected) require.Error(t, err) assert.Contains(t, fmt.Sprintf("%+v", err.Error()), "malformed") }) t.Run("case=get classified credentials", func(t *testing.T) { initial := oidcIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"aylmao-oidc"}, Config: sqlxx.JSONRawMessage(`{"ay":"lmao"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) initial, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.NotEqual(t, uuid.Nil, initial.ID) require.NotEmpty(t, initial.Credentials) t.Run("fails on different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.GetIdentityConfidential(ctx, initial.ID) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=update an identity and set credentials", func(t *testing.T) { initial := oidcIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) assert.Equal(t, config.DefaultIdentityTraitsSchemaID, initial.SchemaID) assert.Equal(t, defaultSchema.SchemaURL(exampleServerURL).String(), initial.SchemaURL) expected := initial.CopyWithoutCredentials() expected.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"ignore-me"}, Config: sqlxx.JSONRawMessage(`{"oh":"nono"}`), }) expected.Traits = identity.Traits(`{"update":"me"}`) expected.SchemaID = altSchema.ID require.NoError(t, p.UpdateIdentity(ctx, expected)) actual, err := p.GetIdentityConfidential(ctx, expected.ID) require.NoError(t, err) assert.Equal(t, altSchema.ID, actual.SchemaID) assert.Equal(t, altSchema.SchemaURL(exampleServerURL).String(), actual.SchemaURL) assert.NotEmpty(t, actual.Credentials[identity.CredentialsTypePassword]) assert.Empty(t, actual.Credentials[identity.CredentialsTypeOIDC]) assert.Equal(t, expected.Credentials[identity.CredentialsTypeOIDC], actual.Credentials[identity.CredentialsTypeOIDC]) t.Run("fails on different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) require.ErrorIs(t, p.UpdateIdentity(ctx, expected), sqlcon.ErrNoRows()) }) }) t.Run("case=fail to update because validation fails", func(t *testing.T) { initial := oidcIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) initial.Traits = identity.Traits(`{"bar":123}`) err := p.UpdateIdentity(ctx, initial) require.Error(t, err) require.Contains(t, err.Error(), "malformed") }) t.Run("case=update an identity column", func(t *testing.T) { initial := oidcIdentity("", x.NewUUID().String()) initial.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.NoAuthenticatorAssuranceLevel) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) initial.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.AuthenticatorAssuranceLevel1) initial.State = identity.StateInactive require.NoError(t, p.UpdateIdentityColumns(ctx, initial, "available_aal")) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) assert.Equal(t, string(identity.AuthenticatorAssuranceLevel1), actual.InternalAvailableAAL.String) assert.Equal(t, identity.StateActive, actual.State, "the state remains unchanged") }) t.Run("case=update external_id via UpdateIdentity", func(t *testing.T) { externalID := sqlxx.NullString("ext-update-" + randx.MustString(10, randx.AlphaNum)) initial := oidcIdentity("", x.NewUUID().String()) initial.ExternalID = externalID require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) t.Run("case=updating other fields keeps external_id unchanged", func(t *testing.T) { // Loaded twice so the from-database snapshot and the update // target are independent objects, matching how the update is // driven in production. fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) updated, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) updated.State = identity.StateInactive require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) assert.Equal(t, identity.StateInactive, actual.State) assert.Equal(t, externalID, actual.ExternalID, "external_id is preserved when it does not change") }) t.Run("case=changing external_id persists the new value", func(t *testing.T) { fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) updated, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) newExternalID := sqlxx.NullString("ext-update-" + randx.MustString(10, randx.AlphaNum)) updated.ExternalID = newExternalID require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) assert.Equal(t, newExternalID, actual.ExternalID) }) t.Run("case=changing external_id to an existing one is rejected", func(t *testing.T) { other := oidcIdentity("", x.NewUUID().String()) otherExternalID := sqlxx.NullString("ext-update-" + randx.MustString(10, randx.AlphaNum)) other.ExternalID = otherExternalID require.NoError(t, p.CreateIdentity(ctx, other)) createdIDs = append(createdIDs, other.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) updated, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) updated.ExternalID = otherExternalID require.Error(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB)), "the external_id unique constraint is still enforced when the value changes") }) }) t.Run("case=should fail to insert identity because credentials from traits exist", func(t *testing.T) { email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" first := passwordIdentity("", email) first.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, first)) createdIDs = append(createdIDs, first.ID) second := passwordIdentity("", email) require.Error(t, p.CreateIdentity(ctx, second)) t.Run("passes on different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) require.NoError(t, p.CreateIdentity(ctx, second)) }) t.Run("case=should fail to update identity because credentials exist", func(t *testing.T) { first := passwordIdentity("", x.NewUUID().String()) first.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, first)) createdIDs = append(createdIDs, first.ID) c := first.Credentials[identity.CredentialsTypePassword] c.Identifiers = []string{email} first.Credentials[identity.CredentialsTypePassword] = c require.Error(t, p.UpdateIdentity(ctx, first)) t.Run("passes on different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) first := passwordIdentity("", x.NewUUID().String()) first.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, first)) c := first.Credentials[identity.CredentialsTypePassword] c.Identifiers = []string{"test-identity@ory.sh"} first.Credentials[identity.CredentialsTypePassword] = c require.NoError(t, p.UpdateIdentity(ctx, first)) }) }) }) t.Run("case=should succeed to update credentials from traits", func(t *testing.T) { expected := passwordIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) expected.Traits = identity.Traits(`{"email":"update-test-identity@ory.sh"}`) require.NoError(t, p.UpdateIdentity(ctx, expected)) actual, err := p.GetIdentityConfidential(ctx, expected.ID) require.NoError(t, err) assert.Equal(t, expected.Credentials[identity.CredentialsTypePassword].Identifiers, actual.Credentials[identity.CredentialsTypePassword].Identifiers) }) t.Run("case=delete an identity", func(t *testing.T) { expected := passwordIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, expected)) t.Run("fails on different network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) require.ErrorIs(t, p.DeleteIdentity(ctx, expected.ID), sqlcon.ErrNoRows()) p = testhelpers.ExistingNetwork(t, p, nid) _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.NoError(t, err) }) require.NoError(t, p.DeleteIdentity(ctx, expected.ID)) _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) require.Error(t, err) }) t.Run("case=create with empty credentials config", func(t *testing.T) { // This test covers a case where the config value of a credentials setting is empty. This causes // issues with postgres' json field. expected := passwordIdentity("", x.NewUUID().String()) expected.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"id-missing-creds-config"}, Config: sqlxx.JSONRawMessage(``), }) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) }) t.Run("case=list", func(t *testing.T) { is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: identity.ExpandDefault}) require.NoError(t, err) require.NotEmpty(t, is) require.Len(t, is, len(createdIDs)) for _, id := range createdIDs { var found bool for _, i := range is { if i.ID == id { expected, err := p.GetIdentity(ctx, id, identity.ExpandDefault) require.NoError(t, err) assertx.EqualAsJSON(t, expected, i) found = true } } require.True(t, found, id) } t.Run("no results on other network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: identity.ExpandDefault}) require.NoError(t, err) assert.Len(t, is, 0) }) t.Run("list some using ids filter", func(t *testing.T) { filterIds := createdIDs[:2] is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: identity.ExpandDefault, IdsFilter: filterIds}) require.NoError(t, err) assert.Len(t, is, len(filterIds)) }) t.Run("eventually consistent", func(t *testing.T) { if dbname != "cockroach" { t.Skipf("Test only works with cockroachdb") return } id := x.NewUUID().String() another := oidcIdentity("", id) require.NoError(t, p.CreateIdentity(ctx, another)) createdIDs = append(createdIDs, another.ID) is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ Expand: identity.ExpandDefault, KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(25)}, ConsistencyLevel: crdbx.ConsistencyLevelStrong, }) require.NoError(t, err) require.Len(t, is, len(createdIDs)) // The data is replicated with eventual consistency (usually a few // seconds). Poll until every identity is visible instead of sleeping // a fixed 8 seconds, so the test returns as soon as replication // completes. listEventually := func() []identity.Identity { // The error is intentionally ignored because the table / schema // might not yet be replicated. This can surface as "cached plan // must not change result type (SQLSTATE 0A000)" because the // prepared query exists but the schema is not yet replicated. is, _, _ := p.ListIdentities(ctx, identity.ListIdentityParameters{ Expand: identity.ExpandEverything, KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(25)}, ConsistencyLevel: crdbx.ConsistencyLevelEventual, }) return is } // Assert the full count and the specific identity on the same // snapshot inside the poll. A follower read can return a stale // snapshot whose count matches len(createdIDs) but that does not // yet contain the just-created identity, so checking the count and // the identity against separate reads is racy. require.EventuallyWithT(t, func(t *assert.CollectT) { results := listEventually() if !assert.Len(t, results, len(createdIDs), "Could not find all identities") { return } var found bool for _, i := range results { if i.ID == another.ID { found = true } } assert.True(t, found, "Unable to find created identity %s in eventually consistent results.", another.ID) }, 15*time.Second, 200*time.Millisecond) }) }) t.Run("case=find identity by its credentials identifier", func(t *testing.T) { var expectedIdentifiers []string var expectedIdentities []*identity.Identity for _, c := range []identity.CredentialsType{ identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn, identity.CredentialsTypeOIDC, } { identityIdentifier := fmt.Sprintf("find-identity-by-identifier-%s@ory.sh", c) expected := identity.NewIdentity("") expected.SetCredentials(c, identity.Credentials{Type: c, Identifiers: []string{identityIdentifier}, Config: sqlxx.JSONRawMessage(`{}`)}) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) expectedIdentifiers = append(expectedIdentifiers, identityIdentifier) expectedIdentities = append(expectedIdentities, expected) } create := identity.NewIdentity("") create.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{Type: identity.CredentialsTypePassword, Identifiers: []string{"find-identity-by-identifier-common@ory.sh"}, Config: sqlxx.JSONRawMessage(`{}`)}) create.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{"find-identity-by-identifier-common@ory.sh"}, Config: sqlxx.JSONRawMessage(`{}`)}) require.NoError(t, p.CreateIdentity(ctx, create)) actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ Expand: identity.ExpandEverything, }) require.NoError(t, err) require.Greater(t, len(actual), 0) for c, ct := range []identity.CredentialsType{ identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn, } { t.Run(ct.String(), func(t *testing.T) { actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ // Match is normalized CredentialsIdentifier: expectedIdentifiers[c], }) require.NoError(t, err) expected := expectedIdentities[c] require.Len(t, actual, 1) assertx.EqualAsJSONExcept(t, expected, actual[0], []string{"credentials.config", "created_at", "updated_at", "state_changed_at", "region"}) }) } t.Run("similarity search", func(t *testing.T) { actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ CredentialsIdentifierSimilar: "find-identity-by-identifier", Expand: identity.ExpandCredentials, }) require.NoError(t, err) assert.Len(t, actual, 4) // webauthn, common, password, oidc outer: for _, e := range append(expectedIdentities[:2], create) { for _, a := range actual { if e.ID == a.ID { assertx.EqualAsJSONExcept(t, e, a, []string{"credentials.config", "created_at", "updated_at", "state_changed_at", "region"}) continue outer } } actualCredentials := make([]map[identity.CredentialsType]identity.Credentials, len(actual)) for k, a := range actual { actualCredentials[k] = a.Credentials } t.Fatalf("expected identity %+v not found in actual result set %+v", e.Credentials, actualCredentials) } }) t.Run("find by OIDC identifier", func(t *testing.T) { actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ CredentialsIdentifier: "find-identity-by-identifier-oidc@ory.sh", Expand: identity.ExpandEverything, }) require.NoError(t, err) assert.Len(t, actual, 1) assert.True(t, next.IsLast()) }) t.Run("one result set even if multiple matches", func(t *testing.T) { actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ CredentialsIdentifier: "find-identity-by-identifier-common@ory.sh", Expand: identity.ExpandEverything, }) require.NoError(t, err) assert.Len(t, actual, 1) assert.True(t, next.IsLast()) }) t.Run("non existing identifier", func(t *testing.T) { actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ CredentialsIdentifier: "find-identity-by-identifier-non-existing@ory.sh", Expand: identity.ExpandEverything, }) require.NoError(t, err) assert.Len(t, actual, 0) assert.True(t, next.IsLast()) }) t.Run("not if on another network", func(t *testing.T) { _, on := testhelpers.NewNetwork(t, ctx, p) actual, next, err := on.ListIdentities(ctx, identity.ListIdentityParameters{ CredentialsIdentifier: expectedIdentifiers[0], Expand: identity.ExpandEverything, }) require.NoError(t, err) assert.Len(t, actual, 0) assert.True(t, next.IsLast()) }) }) t.Run("case=find identity by its credentials type and identifier", func(t *testing.T) { email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" expected := passwordIdentity("", email) expected.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, creds, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, email) require.NoError(t, err) assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, creds.ID) assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].Identifiers, creds.Identifiers) assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(creds.Config)) // assert.EqualValues(t, expected.Credentials[CredentialsTypePassword].CreatedAt.Unix(), creds.CreatedAt.Unix()) // assert.EqualValues(t, expected.Credentials[CredentialsTypePassword].UpdatedAt.Unix(), creds.UpdatedAt.Unix()) require.Equal(t, expected.Traits, actual.Traits) require.Equal(t, expected.ID, actual.ID) require.NotNil(t, actual.Credentials[identity.CredentialsTypePassword]) assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, actual.Credentials[identity.CredentialsTypePassword].ID) assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].Identifiers, actual.Credentials[identity.CredentialsTypePassword].Identifiers) assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(actual.Credentials[identity.CredentialsTypePassword].Config)) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, email) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=find identity by its webauthn credential user handle", func(t *testing.T) { expected := identity.NewIdentity("") expected.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{"find-webauth-user-handle-identifier@ory.sh"}, Config: sqlxx.JSONRawMessage(`{ "credentials": [ { "added_at": "2024-02-13T10:36:16Z", "attestation_type": "none", "authenticator": { "aaguid": "+/wwBxVOTsyMC24CBVfXvQ==", "clone_warning": false, "sign_count": 0 }, "display_name": "Yubikey", "id": "f2uGd/Bg1rGcGXtYp4MT4WcN+eA=", "is_passwordless": true, "public_key": "pQECAyYgASFYIBkNvUxvjdhuA36FworTmS/rxZR1I+NyRWBpoTYY/R+CIlggw+gFFrFoEi+rS82zq7+tDHAukBUJcFpQ7Z3NLBZH5vk=" } ], "user_handle": "51z80nYJTSGmr6UBe1VGLg==" }`), }) expected.Traits = identity.Traits(`{}`) userHandle := x.Must(base64.StdEncoding.DecodeString("51z80nYJTSGmr6UBe1VGLg==")) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, err := p.FindIdentityByWebauthnUserHandle(ctx, userHandle) require.NoError(t, err) expected.Credentials = nil assertEqual(t, expected, actual) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err = p.FindIdentityByWebauthnUserHandle(ctx, userHandle) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=find identity only by credentials identifier", func(t *testing.T) { email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" expected := passwordIdentity("", email) expected.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, err := p.FindIdentityByCredentialIdentifier(ctx, strings.ToUpper(email), false, identity.ExpandDefault) require.NoError(t, err) expected.Credentials = nil assertEqual(t, expected, actual) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindIdentityByCredentialIdentifier(ctx, strings.ToUpper(email), false, identity.ExpandDefault) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=find identity only by credentials identifier case sensitive", func(t *testing.T) { email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" expected := passwordIdentity("", email) expected.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) _, err := p.FindIdentityByCredentialIdentifier(ctx, strings.ToUpper(email), true, identity.ExpandDefault) require.ErrorIs(t, err, sqlcon.ErrNoRows()) actual, err := p.FindIdentityByCredentialIdentifier(ctx, email, true, identity.ExpandDefault) require.NoError(t, err) expected.Credentials = nil assertEqual(t, expected, actual) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindIdentityByCredentialIdentifier(ctx, email, true, identity.ExpandDefault) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=find identity by phone number credential identifier with backward compatibility", func(t *testing.T) { // Use unique phone numbers to avoid conflicts with other tests nonNormalizedPhone := "+44 20 7946 0958" normalizedPhone := "+442079460958" t.Run("upgraded identity with both identifiers via raw SQL", func(t *testing.T) { // This test simulates a legacy identity that has been upgraded and persisted, // so it has BOTH non-normalized and normalized identifiers in the database. // This is the expected state after a user with legacy data logs in and updates settings. // Get credential type metadata var credTypes []identity.CredentialsTypeTable require.NoError(t, p.GetConnection(ctx).All(&credTypes)) var passwordCredType identity.CredentialsTypeTable for _, ct := range credTypes { if ct.Name == identity.CredentialsTypePassword { passwordCredType = ct break } } legacyID := x.NewUUID() credID := x.NewUUID() identifierID1 := x.NewUUID() identifierID2 := x.NewUUID() now := time.Now() // Cleanup after test t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() }) // Insert identity with raw SQL require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", legacyID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert credential (version 1 after upgrade) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, ).Exec()) // Insert BOTH identifiers (simulating post-upgrade state) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", identifierID1, legacyID, credID, nid, nonNormalizedPhone, now, now, passwordCredType.ID, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", identifierID2, legacyID, credID, nid, normalizedPhone, now, now, passwordCredType.ID, ).Exec()) // Test 1: Find with non-normalized phone actualNonNorm, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find upgraded identity with non-normalized phone") require.Equal(t, legacyID, actualNonNorm.ID) // Test 2: Find with normalized phone actualNorm, err := p.FindIdentityByCredentialIdentifier(ctx, normalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find upgraded identity with normalized phone") require.Equal(t, legacyID, actualNorm.ID) // Test 3: Case sensitive should only match exact actualCaseSens, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, true, identity.ExpandDefault) require.NoError(t, err) require.Equal(t, legacyID, actualCaseSens.ID) // Test 4: Case sensitive with normalized should also match exact actualCaseSensNorm, err := p.FindIdentityByCredentialIdentifier(ctx, normalizedPhone, true, identity.ExpandDefault) require.NoError(t, err) require.Equal(t, legacyID, actualCaseSensNorm.ID) }) t.Run("new identity with normalized phone can be found with both formats", func(t *testing.T) { // Create a new identity with normalized phone (simulating post-normalization data) newIdentity := passwordIdentity("", normalizedPhone) newIdentity.Traits = identity.Traits(`{}`) newIdentity.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{normalizedPhone}, // Only normalized identifier Config: newIdentity.Credentials[identity.CredentialsTypePassword].Config, Version: 1, } require.NoError(t, p.CreateIdentity(ctx, newIdentity)) createdIDs = append(createdIDs, newIdentity.ID) // Test 1: Find with normalized phone (exact match in DB) actualNorm, err := p.FindIdentityByCredentialIdentifier(ctx, normalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find new identity with normalized phone") require.Equal(t, newIdentity.ID, actualNorm.ID) // Test 2: Find with non-normalized phone (gets normalized, then matches) actualNonNorm, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find new identity with non-normalized phone (gets normalized)") require.Equal(t, newIdentity.ID, actualNonNorm.ID) }) }) t.Run("case=find identity by credential identifier prefers exact match when multiple records exist", func(t *testing.T) { // This test verifies that when multiple identities match (one with normalized, // another with non-normalized), the function returns the one with exact match. // Use unique phone numbers to avoid conflicts with other tests nonNormalizedPhone := "+1 555-111-2222" normalizedPhone := "+15551112222" // Get credential type metadata var credTypes []identity.CredentialsTypeTable require.NoError(t, p.GetConnection(ctx).All(&credTypes)) var passwordCredType identity.CredentialsTypeTable for _, ct := range credTypes { if ct.Name == identity.CredentialsTypePassword { passwordCredType = ct break } } // Create first identity with normalized phone number normalizedID := x.NewUUID() normalizedCredID := x.NewUUID() normalizedIdentifierID := x.NewUUID() now := time.Now() // Cleanup after test - will be executed in reverse order t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", normalizedCredID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", normalizedCredID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", normalizedID).Exec() }) // Insert first identity with normalized phone require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", normalizedID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", normalizedCredID, normalizedID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", normalizedIdentifierID, normalizedID, normalizedCredID, nid, normalizedPhone, now, now, passwordCredType.ID, ).Exec()) // Create second identity with non-normalized phone number nonNormalizedID := x.NewUUID() nonNormalizedCredID := x.NewUUID() nonNormalizedIdentifierID := x.NewUUID() // Update cleanup to include second identity t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", nonNormalizedCredID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", nonNormalizedCredID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", nonNormalizedID).Exec() }) // Insert second identity with non-normalized phone require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", nonNormalizedID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", nonNormalizedCredID, nonNormalizedID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", nonNormalizedIdentifierID, nonNormalizedID, nonNormalizedCredID, nid, nonNormalizedPhone, now, now, passwordCredType.ID, ).Exec()) // Test: Query with non-normalized phone should return the identity with exact match // Even though both identities match (one has normalized, other has non-normalized), // the function should prefer the exact match actual, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, false, identity.ExpandDefault) require.NoError(t, err, "should find identity with exact non-normalized phone match") require.Equal(t, nonNormalizedID, actual.ID, "should return identity with exact match (non-normalized phone)") require.NotEqual(t, normalizedID, actual.ID, "should not return identity with only normalized match") }) t.Run("case=FindByCredentialsIdentifier with phone number backward compatibility", func(t *testing.T) { t.Run("upgraded identity with both identifiers via raw SQL", func(t *testing.T) { // Use unique phone numbers for this test nonNormalizedPhone := "+1 555-987-6543" normalizedPhone := "+15559876543" // This test simulates a legacy identity that has been upgraded and persisted // Get credential type metadata var credTypes []identity.CredentialsTypeTable require.NoError(t, p.GetConnection(ctx).All(&credTypes)) var passwordCredType identity.CredentialsTypeTable for _, ct := range credTypes { if ct.Name == identity.CredentialsTypePassword { passwordCredType = ct break } } legacyID := x.NewUUID() credID := x.NewUUID() identifierID1 := x.NewUUID() identifierID2 := x.NewUUID() now := time.Now() // Cleanup after test t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() }) // Insert identity with raw SQL require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", legacyID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert credential (version 1 after upgrade) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, ).Exec()) // Insert BOTH identifiers (simulating post-upgrade state) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", identifierID1, legacyID, credID, nid, nonNormalizedPhone, now, now, passwordCredType.ID, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", identifierID2, legacyID, credID, nid, normalizedPhone, now, now, passwordCredType.ID, ).Exec()) // Test 1: Find with non-normalized phone actualNonNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, nonNormalizedPhone) require.NoError(t, err, "should find upgraded identity with non-normalized phone") require.Equal(t, legacyID, actualNonNorm.ID) require.NotNil(t, cred) // Test 2: Find with normalized phone actualNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, normalizedPhone) require.NoError(t, err, "should find upgraded identity with normalized phone") require.Equal(t, legacyID, actualNorm.ID) require.NotNil(t, cred) }) t.Run("new identity with normalized phone", func(t *testing.T) { // Use different phone numbers to avoid conflicts with previous test // Using UK format which normalizes reliably nonNormalizedPhone := "+44 161 496 0123" normalizedPhone := "+441614960123" // Create new identity with normalized phone newIdentity := passwordIdentity("", normalizedPhone) newIdentity.Traits = identity.Traits(`{}`) newIdentity.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{normalizedPhone}, Config: newIdentity.Credentials[identity.CredentialsTypePassword].Config, Version: 1, } require.NoError(t, p.CreateIdentity(ctx, newIdentity)) createdIDs = append(createdIDs, newIdentity.ID) // Test 1: Find with normalized phone (exact match) actualNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, normalizedPhone) require.NoError(t, err) require.Equal(t, newIdentity.ID, actualNorm.ID) require.NotNil(t, cred) // Test 2: Find with non-normalized phone (gets normalized) actualNonNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, nonNormalizedPhone) require.NoError(t, err) require.Equal(t, newIdentity.ID, actualNonNorm.ID) require.NotNil(t, cred) }) }) t.Run("case=find identity by its credentials respects cases", func(t *testing.T) { baseEmail := randx.MustString(16, randx.AlphaLowerNum) caseSensitive := baseEmail + "@ory.sh" caseInsensitiveWithSpaces := " " + strings.ToUpper(baseEmail) + "@ORY.sh " expected := identity.NewIdentity("") for _, c := range []identity.CredentialsType{ identity.CredentialsTypePassword, identity.CredentialsTypeOIDC, identity.CredentialsTypeTOTP, identity.CredentialsTypeLookup, identity.CredentialsTypeWebAuthn, } { expected.SetCredentials(c, identity.Credentials{Type: c, Identifiers: []string{caseSensitive}, Config: sqlxx.JSONRawMessage(`{}`)}) } require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) t.Run("case sensitive", func(t *testing.T) { for _, ct := range []identity.CredentialsType{ identity.CredentialsTypeOIDC, identity.CredentialsTypeTOTP, identity.CredentialsTypeLookup, } { t.Run(ct.String(), func(t *testing.T) { _, _, err := p.FindByCredentialsIdentifier(ctx, ct, caseInsensitiveWithSpaces) require.Error(t, err) actual, creds, err := p.FindByCredentialsIdentifier(ctx, ct, caseSensitive) require.NoError(t, err) assertx.EqualAsJSONExcept(t, expected.Credentials[ct], creds, []string{"created_at", "updated_at", "id"}) assertx.EqualAsJSONExcept(t, expected, actual, []string{"created_at", "state_changed_at", "updated_at", "id", "region"}) }) } }) t.Run("case insensitive", func(t *testing.T) { for _, ct := range []identity.CredentialsType{ identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn, } { t.Run(ct.String(), func(t *testing.T) { for _, cs := range []string{caseSensitive, caseInsensitiveWithSpaces} { actual, creds, err := p.FindByCredentialsIdentifier(ctx, ct, cs) require.NoError(t, err) ec := expected.Credentials[ct] ec.Identifiers = []string{strings.ToLower(caseSensitive)} assertx.EqualAsJSONExcept(t, ec, creds, []string{"created_at", "updated_at", "id", "config.user_handle", "config.credentials", "version"}) assertx.EqualAsJSONExcept(t, expected, actual, []string{"created_at", "state_changed_at", "updated_at", "id", "region"}) } }) } }) }) t.Run("case=find identity by its credentials case insensitive", func(t *testing.T) { identifier := strings.ToUpper(x.NewUUID().String()) expected := passwordIdentity("", identifier) expected.Traits = identity.Traits(`{}`) require.NoError(t, p.CreateIdentity(ctx, expected)) createdIDs = append(createdIDs, expected.ID) actual, creds, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier) require.NoError(t, err) assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, creds.ID) assert.EqualValues(t, []string{strings.ToLower(identifier)}, creds.Identifiers) assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(creds.Config)) require.Equal(t, expected.Traits, actual.Traits) require.Equal(t, expected.ID, actual.ID) require.NotNil(t, actual.Credentials[identity.CredentialsTypePassword]) assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, actual.Credentials[identity.CredentialsTypePassword].ID) assert.EqualValues(t, []string{strings.ToLower(identifier)}, actual.Credentials[identity.CredentialsTypePassword].Identifiers) assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(actual.Credentials[identity.CredentialsTypePassword].Config)) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("suite=verifiable-address", func(t *testing.T) { createIdentityWithAddresses := func(t *testing.T, email string) identity.VerifiableAddress { var i identity.Identity require.NoError(t, faker.FakeData(&i)) address := identity.NewVerifiableEmailAddress(email, i.ID) i.VerifiableAddresses = append(i.VerifiableAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) return i.VerifiableAddresses[0] } t.Run("case=not found", func(t *testing.T) { _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "does-not-exist") require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) transform := func(k int, value string) string { switch k % 5 { case 0: value = strings.ToLower(value) case 1: value = strings.ToUpper(value) case 2: value = " " + value case 3: value = value + " " } return value } t.Run("case=create and find", func(t *testing.T) { addresses := make([]identity.VerifiableAddress, 15) for k := range addresses { value := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" addresses[k] = createIdentityWithAddresses(t, transform(k, value)) require.NotEmpty(t, addresses[k].ID) } compare := func(t *testing.T, expected, actual identity.VerifiableAddress) { actual.CreatedAt = actual.CreatedAt.UTC().Truncate(time.Hour * 24) actual.UpdatedAt = actual.UpdatedAt.UTC().Truncate(time.Hour * 24) expected.CreatedAt = expected.CreatedAt.UTC().Truncate(time.Hour * 24) expected.UpdatedAt = expected.UpdatedAt.UTC().Truncate(time.Hour * 24) expected.Value = strings.TrimSpace(strings.ToLower(expected.Value)) assert.EqualValues(t, expected, actual) } for k, expected := range addresses { t.Run("method=FindVerifiableAddressByValue", func(t *testing.T) { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { actual, err := p.FindVerifiableAddressByValue(ctx, expected.Via, transform(k+1, expected.Value)) require.NoError(t, err) compare(t, expected, *actual) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, expected.Via, transform(k+1, expected.Value)) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) }) } }) t.Run("case=update", func(t *testing.T) { address := createIdentityWithAddresses(t, "verification.TestPersister.Update@ory.sh ") address.Value = "new-codE " require.NoError(t, p.UpdateVerifiableAddress(ctx, &address)) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) require.ErrorIs(t, p.UpdateVerifiableAddress(ctx, &address), sqlcon.ErrNoRows()) }) actual, err := p.FindVerifiableAddressByValue(ctx, address.Via, address.Value) require.NoError(t, err) assert.Equal(t, "new-code", actual.Value) }) t.Run("case=create and update and find", func(t *testing.T) { var i identity.Identity require.NoError(t, faker.FakeData(&i)) address := identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity@ory.sh", i.ID) i.VerifiableAddresses = append(i.VerifiableAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") require.NoError(t, err) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) address = identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity-next@ory.sh", i.ID) i.VerifiableAddresses = []identity.VerifiableAddress{*address} require.NoError(t, p.UpdateIdentity(ctx, &i)) _, err = p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") require.EqualError(t, err, sqlcon.ErrNoRows().Error()) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity-next@ory.sh") require.NoError(t, err) assert.Equal(t, identity.AddressTypeEmail, actual.Via) assert.Equal(t, "verification.testpersister.update-identity-next@ory.sh", actual.Value) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity-next@ory.sh") require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=create and update and find case insensitive", func(t *testing.T) { var i identity.Identity require.NoError(t, faker.FakeData(&i)) address := identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity-case-insensitive@ory.sh", i.ID) i.VerifiableAddresses = append(i.VerifiableAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) require.NoError(t, err) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) address = identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity-case-insensitive-next@ory.sh", i.ID) i.VerifiableAddresses = []identity.VerifiableAddress{*address} require.NoError(t, p.UpdateIdentity(ctx, &i)) _, err = p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) require.EqualError(t, err, sqlcon.ErrNoRows().Error()) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive-next@ory.sh")) require.NoError(t, err) assert.Equal(t, identity.AddressTypeEmail, actual.Via) assert.Equal(t, "verification.testpersister.update-identity-case-insensitive-next@ory.sh", actual.Value) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity-case-insensitive-next@ory.sh") require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=find verifiable address by phone number with normalization", func(t *testing.T) { // Use unique phone numbers to avoid conflicts with other tests nonNormalizedPhone := "+1 650-253-0000" normalizedPhone := "+16502530000" t.Run("create with normalized phone and find with both formats", func(t *testing.T) { var i identity.Identity require.NoError(t, faker.FakeData(&i)) // Create identity with normalized phone address := identity.NewVerifiableAddress(normalizedPhone, i.ID, identity.AddressTypeSMS) i.VerifiableAddresses = append(i.VerifiableAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) createdIDs = append(createdIDs, i.ID) // Test 1: Find with normalized phone (exact match in DB) actualNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) require.NoError(t, err, "should find verifiable address with normalized phone") assert.Equal(t, normalizedPhone, actualNorm.Value) assert.Equal(t, identity.AddressTypeSMS, actualNorm.Via) // Test 2: Find with non-normalized phone (gets normalized, then matches) actualNonNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, nonNormalizedPhone) require.NoError(t, err, "should find verifiable address with non-normalized phone (gets normalized)") assert.Equal(t, normalizedPhone, actualNonNorm.Value) assert.Equal(t, actualNorm.ID, actualNonNorm.ID, "should return same address") }) t.Run("create with non-normalized phone and find with both formats", func(t *testing.T) { // Use different phone number for this test nonNormPhone2 := "+44 20 7946 0958" normPhone2 := "+442079460958" var i identity.Identity require.NoError(t, faker.FakeData(&i)) // Create identity with non-normalized phone // Note: CreateIdentity will normalize the phone during creation address := identity.NewVerifiableAddress(nonNormPhone2, i.ID, identity.AddressTypeSMS) i.VerifiableAddresses = append(i.VerifiableAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) createdIDs = append(createdIDs, i.ID) // Test 1: Find with non-normalized phone (gets normalized for search) actualNonNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, nonNormPhone2) require.NoError(t, err, "should find verifiable address with non-normalized phone") assert.Equal(t, normPhone2, actualNonNorm.Value, "stored value should be normalized") assert.Equal(t, identity.AddressTypeSMS, actualNonNorm.Via) // Test 2: Find with normalized phone (direct match) actualNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, normPhone2) require.NoError(t, err, "should find verifiable address with normalized phone") assert.Equal(t, normPhone2, actualNorm.Value) assert.Equal(t, actualNonNorm.ID, actualNorm.ID, "should return same address") }) t.Run("backward compatibility with legacy non-normalized phone data", func(t *testing.T) { // This test simulates legacy data that was stored before phone normalization was implemented // The database contains a non-normalized phone number legacyPhone := "+1 650-253-0500" identityID := x.NewUUID() addressID := x.NewUUID() now := time.Now() // Cleanup after test t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_verifiable_addresses WHERE id = ?", addressID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() }) // Insert identity with raw SQL require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert verifiable address with NON-NORMALIZED phone (legacy data) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_verifiable_addresses (id, identity_id, nid, via, value, verified, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, false, identity.VerifiableAddressStatusPending, now, now, ).Exec()) // Test: Find with exact non-normalized phone (what user would enter) // Should find the legacy non-normalized phone data actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) require.NoError(t, err, "should find legacy non-normalized phone data with non-normalized search") assert.Equal(t, legacyPhone, actual.Value) assert.Equal(t, identity.AddressTypeSMS, actual.Via) assert.Equal(t, addressID, actual.ID) }) t.Run("post-migration: legacy verifiable address normalized in DB", func(t *testing.T) { legacyPhone := "+1 650-253-0600" normalizedPhone := "+16502530600" identityID := x.NewUUID() addressID := x.NewUUID() now := time.Now() t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_verifiable_addresses WHERE id = ?", addressID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() }) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert with non-normalized phone, then simulate migration require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_verifiable_addresses (id, identity_id, nid, via, value, verified, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, false, identity.VerifiableAddressStatusPending, now, now, ).Exec()) // Simulate migration: normalize the value in DB require.NoError(t, p.GetConnection(ctx).RawQuery( "UPDATE identity_verifiable_addresses SET value = ? WHERE id = ?", normalizedPhone, addressID, ).Exec()) // Find by E.164 actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) require.NoError(t, err, "should find migrated verifiable address by E.164") assert.Equal(t, normalizedPhone, actual.Value) // Find by original non-normalized format (IN query normalizes input) actual2, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) require.NoError(t, err, "should find migrated verifiable address by non-normalized phone") assert.Equal(t, normalizedPhone, actual2.Value) assert.Equal(t, actual.ID, actual2.ID) }) }) }) t.Run("suite=credential-types", func(t *testing.T) { for _, ct := range identity.AllCredentialTypes { t.Run("type="+ct.String(), func(t *testing.T) { id, err := idpersistence.FindIdentityCredentialsTypeByName(p.GetConnection(ctx), ct) require.NoError(t, err) require.NotEqual(t, uuid.Nil, id) name, err := idpersistence.FindIdentityCredentialsTypeByID(p.GetConnection(ctx), id) require.NoError(t, err) assert.Equal(t, ct, name) }) } _, err := idpersistence.FindIdentityCredentialsTypeByName(p.GetConnection(ctx), "unknown") require.Error(t, err) _, err = idpersistence.FindIdentityCredentialsTypeByID(p.GetConnection(ctx), x.NewUUID()) require.Error(t, err) }) t.Run("suite=recovery-address", func(t *testing.T) { createIdentityWithAddresses := func(t *testing.T, email string) *identity.Identity { var i identity.Identity require.NoError(t, faker.FakeData(&i)) i.Traits = []byte(`{"email":"` + email + `"}`) address := identity.NewRecoveryEmailAddress(email, i.ID) i.RecoveryAddresses = append(i.RecoveryAddresses, *address) addressOther := identity.NewRecoveryEmailAddress(email+"_other", i.ID) i.RecoveryAddresses = append(i.RecoveryAddresses, *addressOther) require.NoError(t, p.CreateIdentity(ctx, &i)) return &i } t.Run("case=not found", func(t *testing.T) { _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, "does-not-exist") require.ErrorIs(t, err, sqlcon.ErrNoRows()) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, "does-not-exist") require.NoError(t, err) require.Len(t, allAddresses, 0) }) t.Run("case=create and find", func(t *testing.T) { addresses := make([]identity.RecoveryAddress, 15) for k := range addresses { addresses[k] = createIdentityWithAddresses(t, randx.MustString(16, randx.AlphaLowerNum)+"@ory.sh").RecoveryAddresses[0] require.NotEmpty(t, addresses[k].ID) } compare := func(t *testing.T, expected, actual identity.RecoveryAddress) { actual.CreatedAt = actual.CreatedAt.UTC().Truncate(time.Hour * 24) actual.UpdatedAt = actual.UpdatedAt.UTC().Truncate(time.Hour * 24) expected.CreatedAt = expected.CreatedAt.UTC().Truncate(time.Hour * 24) expected.UpdatedAt = expected.UpdatedAt.UTC().Truncate(time.Hour * 24) assert.EqualValues(t, expected, actual) } for k, expected := range addresses { t.Run("method=FindRecoveryAddressByValue", func(t *testing.T) { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { actual, err := p.FindRecoveryAddressByValue(ctx, expected.Via, expected.Value) require.NoError(t, err) compare(t, expected, *actual) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, expected.Via, expected.Value) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) }) t.Run("method=FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue", func(t *testing.T) { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, expected.Value) require.NoError(t, err) require.Len(t, allAddresses, 2) require.Equal(t, expected.Value, allAddresses[0]) require.Equal(t, expected.Value+"_other", allAddresses[1]) }) t.Run("not if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, expected.Value) require.NoError(t, err) require.Len(t, allAddresses, 0) }) }) } }) t.Run("case=create and update and find", func(t *testing.T) { email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" emailLower := strings.ToLower(email) id := createIdentityWithAddresses(t, email) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) require.NoError(t, err) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) require.NoError(t, err) require.Len(t, allAddresses, 2) require.Equal(t, allAddresses[0], emailLower) require.Equal(t, allAddresses[1], emailLower+"_other") t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) require.ErrorIs(t, err, sqlcon.ErrNoRows()) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) require.NoError(t, err) require.Len(t, allAddresses, 0) }) emailNext := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" id.RecoveryAddresses = []identity.RecoveryAddress{{Via: identity.AddressTypeEmail, Value: emailNext}, {Via: identity.AddressTypeEmail, Value: emailNext + "_other"}} require.NoError(t, p.UpdateIdentity(ctx, id)) _, err = p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) require.EqualError(t, err, sqlcon.ErrNoRows().Error()) allAddresses, err = p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) require.NoError(t, err) require.Len(t, allAddresses, 0) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) require.ErrorIs(t, err, sqlcon.ErrNoRows()) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) require.NoError(t, err) require.Len(t, allAddresses, 0) }) emailNextLower := strings.ToLower(emailNext) actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, emailNext) require.NoError(t, err) assert.Equal(t, identity.AddressTypeEmail, actual.Via) assert.Equal(t, emailNextLower, actual.Value) allAddresses, err = p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailNextLower) require.NoError(t, err) require.Len(t, allAddresses, 2) assert.Equal(t, emailNextLower, allAddresses[0]) assert.Equal(t, emailNextLower+"_other", allAddresses[1]) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, emailNext) require.ErrorIs(t, err, sqlcon.ErrNoRows()) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailNextLower) require.NoError(t, err) require.Len(t, allAddresses, 0) }) }) t.Run("case=create and update and find case insensitive", func(t *testing.T) { id := createIdentityWithAddresses(t, "recovery.TestPersister.Update-case-insensitive@ory.sh") _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive@ory.sh")) require.NoError(t, err) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("Recovery.TestPersister.Update-case-insensitive@ory.sh")) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) id.RecoveryAddresses = []identity.RecoveryAddress{{Via: identity.AddressTypeEmail, Value: "recovery.TestPersister.Update-case-insensitive-next@ory.sh"}} require.NoError(t, p.UpdateIdentity(ctx, id)) _, err = p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive@ory.sh")) require.EqualError(t, err, sqlcon.ErrNoRows().Error()) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive@ory.sh")) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive-next@ory.sh")) require.NoError(t, err) assert.Equal(t, identity.AddressTypeEmail, actual.Via) assert.Equal(t, "recovery.testpersister.update-case-insensitive-next@ory.sh", actual.Value) t.Run("can not find if on another network", func(t *testing.T) { _, p := testhelpers.NewNetwork(t, ctx, p) _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive-next@ory.sh")) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) }) t.Run("case=find recovery address by phone number with normalization", func(t *testing.T) { // Use unique phone numbers to avoid conflicts with other tests nonNormalizedPhone := "+1 650-253-0001" normalizedPhone := "+16502530001" t.Run("create with normalized phone and find with both formats", func(t *testing.T) { var i identity.Identity require.NoError(t, faker.FakeData(&i)) i.Traits = []byte(`{"phone":"` + normalizedPhone + `"}`) // Create identity with normalized phone address := identity.NewRecoverySMSAddress(normalizedPhone, i.ID) i.RecoveryAddresses = append(i.RecoveryAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) createdIDs = append(createdIDs, i.ID) // Test 1: Find with normalized phone (exact match in DB) actualNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) require.NoError(t, err, "should find recovery address with normalized phone") assert.Equal(t, normalizedPhone, actualNorm.Value) assert.Equal(t, identity.AddressTypeSMS, actualNorm.Via) // Test 2: Find with non-normalized phone (gets normalized, then matches) actualNonNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, nonNormalizedPhone) require.NoError(t, err, "should find recovery address with non-normalized phone (gets normalized)") assert.Equal(t, normalizedPhone, actualNonNorm.Value) assert.Equal(t, actualNorm.ID, actualNonNorm.ID, "should return same address") }) t.Run("create with non-normalized phone and find with both formats", func(t *testing.T) { // Use different phone number for this test nonNormPhone2 := "+44 20 7946 0123" normPhone2 := "+442079460123" var i identity.Identity require.NoError(t, faker.FakeData(&i)) i.Traits = []byte(`{"phone":"` + nonNormPhone2 + `"}`) // Create identity with non-normalized phone // Note: CreateIdentity will normalize the phone during creation address := identity.NewRecoverySMSAddress(nonNormPhone2, i.ID) i.RecoveryAddresses = append(i.RecoveryAddresses, *address) require.NoError(t, p.CreateIdentity(ctx, &i)) createdIDs = append(createdIDs, i.ID) // Test 1: Find with non-normalized phone (gets normalized for search) actualNonNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, nonNormPhone2) require.NoError(t, err, "should find recovery address with non-normalized phone") assert.Equal(t, normPhone2, actualNonNorm.Value, "stored value should be normalized") assert.Equal(t, identity.AddressTypeSMS, actualNonNorm.Via) // Test 2: Find with normalized phone (direct match) actualNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, normPhone2) require.NoError(t, err, "should find recovery address with normalized phone") assert.Equal(t, normPhone2, actualNorm.Value) assert.Equal(t, actualNonNorm.ID, actualNorm.ID, "should return same address") }) t.Run("backward compatibility with legacy non-normalized phone data", func(t *testing.T) { // This test simulates legacy data that was stored before phone normalization was implemented // The database contains a non-normalized phone number legacyPhone := "+1 650-253-0600" identityID := x.NewUUID() addressID := x.NewUUID() now := time.Now() // Cleanup after test t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_recovery_addresses WHERE id = ?", addressID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() }) // Insert identity with raw SQL require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert recovery address with NON-NORMALIZED phone (legacy data) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, now, now, ).Exec()) // Test: Find with exact non-normalized phone (what user would enter) // Should find the legacy non-normalized phone data actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) require.NoError(t, err, "should find legacy non-normalized phone data with non-normalized search") assert.Equal(t, legacyPhone, actual.Value) assert.Equal(t, identity.AddressTypeSMS, actual.Via) assert.Equal(t, addressID, actual.ID) }) t.Run("post-migration: legacy recovery address normalized in DB", func(t *testing.T) { legacyPhone := "+1 650-253-0700" normalizedPhone := "+16502530700" identityID := x.NewUUID() addressID := x.NewUUID() now := time.Now() t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_recovery_addresses WHERE id = ?", addressID).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() }) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert with non-normalized phone, then simulate migration require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, now, now, ).Exec()) // Simulate migration: normalize the value in DB require.NoError(t, p.GetConnection(ctx).RawQuery( "UPDATE identity_recovery_addresses SET value = ? WHERE id = ?", normalizedPhone, addressID, ).Exec()) // Find by E.164 actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) require.NoError(t, err, "should find migrated recovery address by E.164") assert.Equal(t, normalizedPhone, actual.Value) // Find by original non-normalized format actual2, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) require.NoError(t, err, "should find migrated recovery address by non-normalized phone") assert.Equal(t, normalizedPhone, actual2.Value) assert.Equal(t, actual.ID, actual2.ID) }) }) t.Run("case=find all recovery addresses by phone number with normalization", func(t *testing.T) { t.Run("create with multiple SMS addresses and find all with normalized phone", func(t *testing.T) { // Use unique phone numbers to avoid conflicts with other tests nonNormalizedPhone1 := "+1 650-253-0100" normalizedPhone1 := "+16502530100" normalizedPhone2 := "+16502530200" var i identity.Identity require.NoError(t, faker.FakeData(&i)) i.Traits = []byte(`{"phone":"` + normalizedPhone1 + `"}`) // Create identity with two SMS recovery addresses address1 := identity.NewRecoverySMSAddress(normalizedPhone1, i.ID) address2 := identity.NewRecoverySMSAddress(normalizedPhone2, i.ID) i.RecoveryAddresses = append(i.RecoveryAddresses, *address1, *address2) require.NoError(t, p.CreateIdentity(ctx, &i)) createdIDs = append(createdIDs, i.ID) // Test 1: Find all addresses using normalized phone1 allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, normalizedPhone1) require.NoError(t, err, "should find all recovery addresses with normalized phone") require.Len(t, allAddresses, 2, "should return all recovery addresses for the identity") assert.Equal(t, normalizedPhone1, allAddresses[0]) assert.Equal(t, normalizedPhone2, allAddresses[1]) // Test 2: Find all addresses using non-normalized phone1 (gets normalized, then matches) allAddresses2, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, nonNormalizedPhone1) require.NoError(t, err, "should find all recovery addresses with non-normalized phone") require.Len(t, allAddresses2, 2, "should return all recovery addresses when querying with non-normalized phone") assert.Equal(t, normalizedPhone1, allAddresses2[0]) assert.Equal(t, normalizedPhone2, allAddresses2[1]) // Test 3: Find all addresses using normalized phone2 allAddresses3, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, normalizedPhone2) require.NoError(t, err) require.Len(t, allAddresses3, 2, "should return all recovery addresses when querying with second phone") assert.Equal(t, normalizedPhone1, allAddresses3[0]) assert.Equal(t, normalizedPhone2, allAddresses3[1]) }) t.Run("create with multiple SMS addresses and find all with non-normalized phone", func(t *testing.T) { // Use different phone numbers for this test nonNormPhone1 := "+1 650-253-0300" normPhone1 := "+16502530300" normPhone2 := "+16502530400" var i identity.Identity require.NoError(t, faker.FakeData(&i)) i.Traits = []byte(`{"phone":"` + nonNormPhone1 + `"}`) // Create identity with two SMS recovery addresses // Note: CreateIdentity will normalize both phones during creation address1 := identity.NewRecoverySMSAddress(nonNormPhone1, i.ID) address2 := identity.NewRecoverySMSAddress(normPhone2, i.ID) i.RecoveryAddresses = append(i.RecoveryAddresses, *address1, *address2) require.NoError(t, p.CreateIdentity(ctx, &i)) createdIDs = append(createdIDs, i.ID) // Test 1: Find all addresses using non-normalized phone1 (gets normalized for search) allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, nonNormPhone1) require.NoError(t, err, "should find all recovery addresses with non-normalized phone") require.Len(t, allAddresses, 2, "should return all recovery addresses for the identity") assert.Equal(t, normPhone1, allAddresses[0], "stored values should be normalized") assert.Equal(t, normPhone2, allAddresses[1]) // Test 2: Find all addresses using normalized phone1 (direct match) allAddresses2, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, normPhone1) require.NoError(t, err) require.Len(t, allAddresses2, 2, "should return all recovery addresses when querying with normalized phone") assert.Equal(t, normPhone1, allAddresses2[0]) assert.Equal(t, normPhone2, allAddresses2[1]) }) t.Run("backward compatibility with legacy non-normalized phone data", func(t *testing.T) { // This test simulates legacy data that was stored before phone normalization was implemented // The database contains non-normalized phone numbers // // SECURITY NOTE: This function returns ALL recovery addresses for an identity when ANY ONE matches. // The implementation must ensure it only matches addresses for the SAME identity and doesn't // accidentally return addresses from different identities that might have similar phone numbers. legacyPhone1 := "+1 650-253-0700" legacyPhone2 := "+1 650-253-0800" identityID := x.NewUUID() addressID1 := x.NewUUID() addressID2 := x.NewUUID() now := time.Now() // Cleanup after test t.Cleanup(func() { _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_recovery_addresses WHERE id IN (?, ?)", addressID1, addressID2).Exec() _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() }) // Insert identity with raw SQL require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, ).Exec()) // Insert TWO recovery addresses with NON-NORMALIZED phones (legacy data) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", addressID1, identityID, nid, identity.AddressTypeSMS, legacyPhone1, now, now, ).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery( "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", addressID2, identityID, nid, identity.AddressTypeSMS, legacyPhone2, now, now, ).Exec()) // Test: Find all addresses with exact non-normalized phone (what user would enter) // Should find all recovery addresses for the identity allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, legacyPhone1) require.NoError(t, err, "should find all recovery addresses with non-normalized search") require.Len(t, allAddresses, 2, "should return all recovery addresses for the identity") assert.Equal(t, legacyPhone1, allAddresses[0]) assert.Equal(t, legacyPhone2, allAddresses[1]) }) }) }) t.Run("network reference isolation", func(t *testing.T) { nid1, p := testhelpers.NewNetwork(t, ctx, p) nid2, _ := testhelpers.NewNetwork(t, ctx, p) var m []identity.CredentialsTypeTable require.NoError(t, p.GetConnection(ctx).All(&m)) iid := x.NewUUID() require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at) VALUES (?, ?, 'default', '{}', ?, ?)", iid, nid1, time.Now(), time.Now()).Exec()) cid1, cid2 := x.NewUUID(), x.NewUUID() require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config) VALUES (?, ?, ?, ?, ?, ?, '{}')", cid1, iid, nid1, m[0].ID, time.Now(), time.Now()).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config) VALUES (?, ?, ?, ?, ?, ?, '{}')", cid2, iid, nid2, m[0].ID, time.Now(), time.Now()).Exec()) ici1, ici2 := x.NewUUID(), x.NewUUID() require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ici1, iid, cid1, nid1, "nid1", time.Now(), time.Now(), m[0].ID).Exec()) require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ici2, iid, cid2, nid2, "nid2", time.Now(), time.Now(), m[0].ID).Exec()) _, err := p.GetIdentity(ctx, nid1, identity.ExpandNothing) require.ErrorIs(t, err, sqlcon.ErrNoRows()) _, err = p.GetIdentityConfidential(ctx, nid1) require.ErrorIs(t, err, sqlcon.ErrNoRows()) i, c, err := p.FindByCredentialsIdentifier(ctx, m[0].Name, "nid1") require.NoError(t, err) assert.Equal(t, "nid1", c.Identifiers[0]) require.Len(t, i.Credentials, 1) _, _, err = p.FindByCredentialsIdentifier(ctx, m[0].Name, "nid2") require.ErrorIs(t, err, sqlcon.ErrNoRows()) i, err = p.GetIdentityConfidential(ctx, iid) require.NoError(t, err) require.Len(t, i.Credentials, 1) assert.Equal(t, "nid1", i.Credentials[m[0].Name].Identifiers[0]) }) t.Run("suite=update-verifiable-addresses-edge-cases", func(t *testing.T) { t.Run("case=add new verifiable addresses", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) originalEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" new1Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" new2Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" initial.VerifiableAddresses = []identity.VerifiableAddress{ {Value: originalEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.VerifiableAddresses, 1) // Add two new addresses updated := fromDB.CopyWithoutCredentials() updated.VerifiableAddresses = append(updated.VerifiableAddresses, identity.VerifiableAddress{Value: new1Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, identity.VerifiableAddress{Value: new2Email, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, ) require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.VerifiableAddresses, 3) values := []string{actual.VerifiableAddresses[0].Value, actual.VerifiableAddresses[1].Value, actual.VerifiableAddresses[2].Value} assertContainsValues(t, values, []string{originalEmail, new1Email, new2Email}, nil) // Verify the new verified address has verified_at set for _, addr := range actual.VerifiableAddresses { if addr.Value == new2Email { assert.True(t, addr.Verified) assert.NotNil(t, addr.VerifiedAt) } } }) t.Run("case=remove all verifiable addresses", func(t *testing.T) { email1 := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" email2 := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" initial := passwordIdentity("", x.NewUUID().String()) initial.VerifiableAddresses = []identity.VerifiableAddress{ {Value: email1, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, {Value: email2, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.VerifiableAddresses, 2) // Remove all addresses updated := fromDB.CopyWithoutCredentials() updated.VerifiableAddresses = []identity.VerifiableAddress{} require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) assert.Len(t, actual.VerifiableAddresses, 0) }) t.Run("case=remove some and add some verifiable addresses", func(t *testing.T) { keepEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" removeEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" addEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" initial := passwordIdentity("", x.NewUUID().String()) initial.VerifiableAddresses = []identity.VerifiableAddress{ {Value: keepEmail, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, {Value: removeEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.VerifiableAddresses, 2) // Keep one, remove one, add one updated := fromDB.CopyWithoutCredentials() var keptAddress identity.VerifiableAddress for _, addr := range updated.VerifiableAddresses { if addr.Value == keepEmail { keptAddress = addr break } } updated.VerifiableAddresses = []identity.VerifiableAddress{ keptAddress, {Value: addEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusSent}, } require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.VerifiableAddresses, 2) values := []string{actual.VerifiableAddresses[0].Value, actual.VerifiableAddresses[1].Value} assertContainsValues(t, values, []string{keepEmail, addEmail}, []string{removeEmail}) }) t.Run("case=update existing verifiable address properties", func(t *testing.T) { changeEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" initial := passwordIdentity("", x.NewUUID().String()) initial.VerifiableAddresses = []identity.VerifiableAddress{ {Value: changeEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) oldAddr := fromDB.VerifiableAddresses[0] assert.False(t, oldAddr.Verified) assert.Nil(t, oldAddr.VerifiedAt) // Change the address value - this should be treated as removal + addition updated := fromDB.CopyWithoutCredentials() updated.VerifiableAddresses = []identity.VerifiableAddress{ {Value: changeEmail, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, } require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.VerifiableAddresses, 1) assert.Equal(t, changeEmail, actual.VerifiableAddresses[0].Value) assert.True(t, actual.VerifiableAddresses[0].Verified) assert.NotNil(t, actual.VerifiableAddresses[0].VerifiedAt) }) t.Run("case=replace all verifiable addresses at once", func(t *testing.T) { old1Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" old2Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" old3Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" initial := passwordIdentity("", x.NewUUID().String()) initial.VerifiableAddresses = []identity.VerifiableAddress{ {Value: old1Email, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, {Value: old2Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, {Value: old3Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusSent}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.VerifiableAddresses, 3) new1Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" new2Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" // Replace all addresses with new ones updated := fromDB.CopyWithoutCredentials() updated.VerifiableAddresses = []identity.VerifiableAddress{ {Value: new1Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, {Value: new2Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, } require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.VerifiableAddresses, 2) values := []string{actual.VerifiableAddresses[0].Value, actual.VerifiableAddresses[1].Value} assertContainsValues(t, values, []string{new1Email, new2Email}, []string{old1Email, old2Email, old3Email}) }) }) t.Run("suite=update-recovery-addresses-edge-cases", func(t *testing.T) { t.Run("case=add new recovery addresses", func(t *testing.T) { initialEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" recovery1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" recovery2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" initial := passwordIdentity("", x.NewUUID().String()) initial.RecoveryAddresses = []identity.RecoveryAddress{ {Value: initialEmail, Via: identity.AddressTypeEmail}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.RecoveryAddresses, 1) // Add two new addresses updated := fromDB.CopyWithoutCredentials() updated.RecoveryAddresses = append(updated.RecoveryAddresses, identity.RecoveryAddress{Value: recovery1Email, Via: identity.AddressTypeEmail}, identity.RecoveryAddress{Value: recovery2Email, Via: identity.AddressTypeEmail}, ) require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.RecoveryAddresses, 3) values := []string{actual.RecoveryAddresses[0].Value, actual.RecoveryAddresses[1].Value, actual.RecoveryAddresses[2].Value} assertContainsValues(t, values, []string{initialEmail, recovery1Email, recovery2Email}, nil) }) t.Run("case=remove all recovery addresses", func(t *testing.T) { remove1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" remove2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" initial := passwordIdentity("", x.NewUUID().String()) initial.RecoveryAddresses = []identity.RecoveryAddress{ {Value: remove1Email, Via: identity.AddressTypeEmail}, {Value: remove2Email, Via: identity.AddressTypeEmail}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.RecoveryAddresses, 2) // Remove all addresses updated := fromDB.CopyWithoutCredentials() updated.RecoveryAddresses = []identity.RecoveryAddress{} require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) assert.Len(t, actual.RecoveryAddresses, 0) }) t.Run("case=remove some and add some recovery addresses", func(t *testing.T) { keepEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" removeEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" addEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" initial := passwordIdentity("", x.NewUUID().String()) initial.RecoveryAddresses = []identity.RecoveryAddress{ {Value: keepEmail, Via: identity.AddressTypeEmail}, {Value: removeEmail, Via: identity.AddressTypeEmail}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.RecoveryAddresses, 2) // Keep one, remove one, add one updated := fromDB.CopyWithoutCredentials() var keptAddress identity.RecoveryAddress for _, addr := range updated.RecoveryAddresses { if addr.Value == keepEmail { keptAddress = addr break } } updated.RecoveryAddresses = []identity.RecoveryAddress{ keptAddress, {Value: addEmail, Via: identity.AddressTypeEmail}, } require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.RecoveryAddresses, 2) values := []string{actual.RecoveryAddresses[0].Value, actual.RecoveryAddresses[1].Value} assertContainsValues(t, values, []string{keepEmail, addEmail}, []string{removeEmail}) }) t.Run("case=replace all recovery addresses at once", func(t *testing.T) { old1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" old2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" old3Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" new1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" new2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" initial := passwordIdentity("", x.NewUUID().String()) initial.RecoveryAddresses = []identity.RecoveryAddress{ {Value: old1Email, Via: identity.AddressTypeEmail}, {Value: old2Email, Via: identity.AddressTypeEmail}, {Value: old3Email, Via: identity.AddressTypeEmail}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, fromDB.RecoveryAddresses, 3) // Replace all addresses with new ones updated := fromDB.CopyWithoutCredentials() updated.RecoveryAddresses = []identity.RecoveryAddress{ {Value: new1Email, Via: identity.AddressTypeEmail}, {Value: new2Email, Via: identity.AddressTypeEmail}, } require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) require.Len(t, actual.RecoveryAddresses, 2) values := []string{actual.RecoveryAddresses[0].Value, actual.RecoveryAddresses[1].Value} assertContainsValues(t, values, []string{new1Email, new2Email}, []string{old1Email, old2Email, old3Email}) }) }) t.Run("suite=update-credentials-edge-cases", func(t *testing.T) { t.Run("case=add new credential type", func(t *testing.T) { totpIdentifier := randx.MustString(16, randx.AlphaLowerNum) initial := passwordIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 1) _, hasPassword := fromDB.Credentials[identity.CredentialsTypePassword] assert.True(t, hasPassword) oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID // Add TOTP credential initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{totpIdentifier}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }) require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 2) _, hasPassword = actual.Credentials[identity.CredentialsTypePassword] _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] assert.True(t, hasPassword) assert.True(t, hasTOTP) assert.Equal(t, []string{totpIdentifier}, actual.Credentials[identity.CredentialsTypeTOTP].Identifiers) // Verify that the password credential was not recreated (ID should remain the same) assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated when adding TOTP") }) t.Run("case=remove all credentials", func(t *testing.T) { oidcIdentifier := randx.MustString(16, randx.AlphaLowerNum) initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{oidcIdentifier}, Config: sqlxx.JSONRawMessage(`{}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 2) // Remove all credentials initial.Credentials = map[identity.CredentialsType]identity.Credentials{} require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) assert.Len(t, actual.Credentials, 0) }) t.Run("case=remove one credential type and keep others", func(t *testing.T) { oidcIdentifier := randx.MustString(16, randx.AlphaLowerNum) totpIdentifier := randx.MustString(16, randx.AlphaLowerNum) initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{oidcIdentifier}, Config: sqlxx.JSONRawMessage(`{}`), }) initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{totpIdentifier}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 3) oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID oldTOTPCredID := fromDB.Credentials[identity.CredentialsTypeTOTP].ID // Remove password credential, keep OIDC and TOTP delete(initial.Credentials, identity.CredentialsTypePassword) require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 2) _, hasPassword := actual.Credentials[identity.CredentialsTypePassword] _, hasOIDC := actual.Credentials[identity.CredentialsTypeOIDC] _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] assert.False(t, hasPassword) assert.True(t, hasOIDC) assert.True(t, hasTOTP) // Verify that OIDC and TOTP credentials were not recreated (IDs should remain the same) assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when removing password") assert.Equal(t, oldTOTPCredID, actual.Credentials[identity.CredentialsTypeTOTP].ID, "TOTP credential should not be recreated when removing password") }) t.Run("case=update credential config and identifiers", func(t *testing.T) { oldEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" newEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" initial := passwordIdentity("", oldEmail) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) oldCred := fromDB.Credentials[identity.CredentialsTypePassword] // Update password credential with new identifier and config initial.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{newEmail}, Config: sqlxx.JSONRawMessage(`{"new":"config"}`), }) require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) newCred := actual.Credentials[identity.CredentialsTypePassword] assert.NotEqual(t, oldCred.ID, newCred.ID) assert.Equal(t, []string{newEmail}, newCred.Identifiers) assert.JSONEq(t, `{"new":"config"}`, string(newCred.Config)) }) t.Run("case=replace all credentials at once", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"oidc-replace"}, Config: sqlxx.JSONRawMessage(`{}`), }) initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{"totp-replace"}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 3) // Replace all credentials with webauthn initial.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypeWebAuthn: { Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{"webauthn-new"}, Config: sqlxx.JSONRawMessage(`{"credentials":[]}`), }, } require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 1) _, hasWebAuthn := actual.Credentials[identity.CredentialsTypeWebAuthn] assert.True(t, hasWebAuthn) assert.Equal(t, []string{"webauthn-new"}, actual.Credentials[identity.CredentialsTypeWebAuthn].Identifiers) }) t.Run("case=update with no changes", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"oidc-no-change"}, Config: sqlxx.JSONRawMessage(`{}`), }) initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{"totp-no-change"}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 3) oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID oldTOTPCredID := fromDB.Credentials[identity.CredentialsTypeTOTP].ID // Update without changing anything require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) // Verify no credentials were added or removed require.Len(t, actual.Credentials, 3, "credential count should not change when nothing is modified") // Verify all credential IDs remained the same (nothing was recreated) assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated when nothing changes") assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when nothing changes") assert.Equal(t, oldTOTPCredID, actual.Credentials[identity.CredentialsTypeTOTP].ID, "TOTP credential should not be recreated when nothing changes") }) t.Run("case=update with json whitespace differences", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) // Create with compact JSON initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"oidc-whitespace"}, Config: sqlxx.JSONRawMessage(`{"foo":"bar","baz":"qux"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 2) oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID // Update with same JSON but different whitespace formatting initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"oidc-whitespace"}, // Same JSON content but with different whitespace Config: sqlxx.JSONRawMessage(`{ "foo": "bar", "baz": "qux" }`), }) require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) // Verify no credentials were added or removed require.Len(t, actual.Credentials, 2, "credential count should not change") // Verify credential IDs remained the same (nothing was recreated despite JSON formatting difference) assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated") assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when JSON has different whitespace") }) t.Run("case=update traits with fromDatabase parameter", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.Traits = identity.Traits(`{"email":"initial@ory.sh","name":"Initial Name"}`) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) // Update traits using DiffAgainst updated := fromDB.CopyWithoutCredentials() updated.Traits = identity.Traits(`{"email":"updated@ory.sh","name":"Updated Name"}`) require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) require.NoError(t, err) assert.JSONEq(t, `{"email":"updated@ory.sh","name":"Updated Name"}`, string(actual.Traits)) }) t.Run("case=update without fromDatabase parameter", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"oidc-no-from-db"}, Config: sqlxx.JSONRawMessage(`{}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 2) oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID // Update without providing fromDatabase - should fetch from DB internally updated := *fromDB updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{"totp-no-from-db"}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }) require.NoError(t, p.UpdateIdentity(ctx, &updated)) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 3) _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] assert.True(t, hasTOTP) // Verify that password and OIDC credentials were not recreated (IDs should remain the same) assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated when adding TOTP without fromDatabase") assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when adding TOTP without fromDatabase") }) t.Run("case=WithoutCredentialTypes leaves the excluded credential untouched", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{"totp-excluded-" + x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/old"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 2) dbPasswordCred := fromDB.Credentials[identity.CredentialsTypePassword] dbTOTPCred := fromDB.Credentials[identity.CredentialsTypeTOTP] // The in-memory password config diverges from the database, as if a // concurrent UpdateCredentialsConfig had won. Password is excluded, // so its row must survive while TOTP still updates. updated := *fromDB updated.Credentials = nil updated.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: dbPasswordCred.Identifiers, Config: sqlxx.JSONRawMessage(`{"foo":"stale-in-memory"}`), Version: dbPasswordCred.Version, }) updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: dbTOTPCred.Identifiers, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/new"}`), }) updated.Traits = identity.Traits(`{"email":"excluded-update@ory.sh"}`) require.NoError(t, p.UpdateIdentity(ctx, &updated, identity.DiffAgainst(fromDB), identity.WithoutCredentialTypes(identity.CredentialsTypePassword))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 2) // The excluded row is untouched: same row ID, same config. assert.Equal(t, dbPasswordCred.ID, actual.Credentials[identity.CredentialsTypePassword].ID, "excluded credential must not be recreated") assert.JSONEq(t, string(dbPasswordCred.Config), string(actual.Credentials[identity.CredentialsTypePassword].Config), "excluded credential config must not change") // The non-excluded credential still updates. assert.JSONEq(t, `{"totp_url":"otpauth://totp/new"}`, string(actual.Credentials[identity.CredentialsTypeTOTP].Config)) // Identity-level changes still persist. assert.JSONEq(t, `{"email":"excluded-update@ory.sh"}`, string(actual.Traits)) // The returned identity carries the database state of the excluded // type, not the stale in-memory copy. assert.Equal(t, dbPasswordCred.ID, updated.Credentials[identity.CredentialsTypePassword].ID) assert.JSONEq(t, string(dbPasswordCred.Config), string(updated.Credentials[identity.CredentialsTypePassword].Config)) }) t.Run("case=WithoutCredentialTypes without DiffAgainst leaves the excluded credential untouched", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) dbPasswordCred := fromDB.Credentials[identity.CredentialsTypePassword] updated := *fromDB updated.Credentials = nil updated.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: dbPasswordCred.Identifiers, Config: sqlxx.JSONRawMessage(`{"foo":"stale-in-memory"}`), Version: dbPasswordCred.Version, }) require.NoError(t, p.UpdateIdentity(ctx, &updated, identity.WithoutCredentialTypes(identity.CredentialsTypePassword))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 1) assert.Equal(t, dbPasswordCred.ID, actual.Credentials[identity.CredentialsTypePassword].ID, "excluded credential must not be recreated") assert.JSONEq(t, string(dbPasswordCred.Config), string(actual.Credentials[identity.CredentialsTypePassword].Config), "excluded credential config must not change") // The returned identity carries the database state of the excluded type. assert.JSONEq(t, string(dbPasswordCred.Config), string(updated.Credentials[identity.CredentialsTypePassword].Config)) }) t.Run("case=WithoutCredentialTypes excludes two credential types at once", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: []string{"oidc-two-excluded-" + x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(`{}`), }) initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{"totp-two-excluded-" + x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/old"}`), }) require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, fromDB.Credentials, 3) dbPasswordCred := fromDB.Credentials[identity.CredentialsTypePassword] dbOIDCCred := fromDB.Credentials[identity.CredentialsTypeOIDC] dbTOTPCred := fromDB.Credentials[identity.CredentialsTypeTOTP] // Both excluded types diverge in memory; both rows must survive // while the non-excluded TOTP credential still updates. updated := *fromDB updated.Credentials = nil updated.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: dbPasswordCred.Identifiers, Config: sqlxx.JSONRawMessage(`{"foo":"stale-password"}`), Version: dbPasswordCred.Version, }) updated.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ Type: identity.CredentialsTypeOIDC, Identifiers: dbOIDCCred.Identifiers, Config: sqlxx.JSONRawMessage(`{"foo":"stale-oidc"}`), Version: dbOIDCCred.Version, }) updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: dbTOTPCred.Identifiers, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/new"}`), }) require.NoError(t, p.UpdateIdentity(ctx, &updated, identity.DiffAgainst(fromDB), identity.WithoutCredentialTypes(identity.CredentialsTypePassword, identity.CredentialsTypeOIDC))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.Credentials, 3) // Both excluded rows are untouched: same row IDs, same configs. assert.Equal(t, dbPasswordCred.ID, actual.Credentials[identity.CredentialsTypePassword].ID, "excluded password credential must not be recreated") assert.JSONEq(t, string(dbPasswordCred.Config), string(actual.Credentials[identity.CredentialsTypePassword].Config), "excluded password config must not change") assert.Equal(t, dbOIDCCred.ID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "excluded OIDC credential must not be recreated") assert.JSONEq(t, string(dbOIDCCred.Config), string(actual.Credentials[identity.CredentialsTypeOIDC].Config), "excluded OIDC config must not change") // The non-excluded credential still updates. assert.JSONEq(t, `{"totp_url":"otpauth://totp/new"}`, string(actual.Credentials[identity.CredentialsTypeTOTP].Config)) }) }) t.Run("suite=update-combined-changes", func(t *testing.T) { t.Run("case=update addresses and credentials simultaneously", func(t *testing.T) { initial := passwordIdentity("", x.NewUUID().String()) initial.VerifiableAddresses = []identity.VerifiableAddress{ {Value: "combined-verify@ory.sh", Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, } initial.RecoveryAddresses = []identity.RecoveryAddress{ {Value: "combined-recovery@ory.sh", Via: identity.AddressTypeEmail}, } require.NoError(t, p.CreateIdentity(ctx, initial)) createdIDs = append(createdIDs, initial.ID) fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) // Change everything at once updated := *fromDB updated.VerifiableAddresses = []identity.VerifiableAddress{ {Value: "combined-verify-new@ory.sh", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, } updated.RecoveryAddresses = []identity.RecoveryAddress{ {Value: "combined-recovery-new@ory.sh", Via: identity.AddressTypeEmail}, } updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ Type: identity.CredentialsTypeTOTP, Identifiers: []string{"combined-totp"}, Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), }) require.NoError(t, p.UpdateIdentity(ctx, &updated, identity.DiffAgainst(fromDB))) actual, err := p.GetIdentityConfidential(ctx, initial.ID) require.NoError(t, err) require.Len(t, actual.VerifiableAddresses, 1) require.Len(t, actual.RecoveryAddresses, 1) require.Len(t, actual.Credentials, 2) assert.Equal(t, "combined-verify-new@ory.sh", actual.VerifiableAddresses[0].Value) assert.Equal(t, "combined-recovery-new@ory.sh", actual.RecoveryAddresses[0].Value) _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] assert.True(t, hasTOTP) }) }) t.Run("case=UpdateCredentialsConfig does not lose concurrent increments", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"cas-key-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, p.CreateIdentity(ctx, id)) created, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) createdCreds, ok := created.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) versionBefore := createdCreds.Version // Fire N concurrent read-modify-write increments; the final counter // must equal exactly N. SQLite serializes writes, so real contention // is exercised on the concurrent engines. const N = 12 var wg sync.WaitGroup for range N { wg.Go(func() { err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { var parsed map[string]int if err := json.Unmarshal(cfg, &parsed); err != nil { return nil, err } parsed["failed_attempts"]++ return json.Marshal(parsed) }) // Assert in a goroutine without FailNow to stay concurrency-safe. assert.NoError(t, err) }) } wg.Wait() reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) var counts map[string]int require.NoError(t, json.Unmarshal(c.Config, &counts)) assert.Equal(t, N, counts["failed_attempts"], "no increment may be lost") // The version column versions the *shape* of the config (its schema), // not its content. Content updates must not consume it, or future // schema migrations keyed on version become impossible. assert.Equal(t, versionBefore, c.Version, "content updates must not bump the config-schema version column") }) t.Run("case=UpdateCredentialsConfig survives concurrent plain writes", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"plain-writes-key-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, p.CreateIdentity(ctx, id)) // Plain single-statement writes commit freely while the locked // mutation's transaction is open: pop's SQLite transaction mutex // does not cover them, and on the cluster databases they contend // only on row locks. They model the flow and session writes that // surround a login burst. stop := make(chan struct{}) var noise sync.WaitGroup var noiseWrites atomic.Int64 for w := range 3 { noise.Go(func() { // Bind a value that changes on every write: SQLite skips the // page write (and thus the WAL commit) when an UPDATE leaves // the row byte-identical, and a commit that appends nothing // stales nobody's snapshot. ts := time.Now().Add(time.Duration(w) * time.Hour) for { select { case <-stop: return default: } ts = ts.Add(time.Second) if err := p.GetConnection(ctx).RawQuery( "UPDATE identities SET updated_at = ? WHERE id = ?", ts, id.ID).Exec(); err != nil { return } noiseWrites.Add(1) } }) } t.Cleanup(func() { close(stop) noise.Wait() require.NotZero(t, noiseWrites.Load(), "the concurrent writer must actually have written") }) const n = 10 for i := range n { require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { var parsed map[string]int if err := json.Unmarshal(cfg, &parsed); err != nil { return nil, err } parsed["failed_attempts"]++ // Give the concurrent writers room to commit between this // transaction's read and its write, so the race is a // certainty instead of a scheduling accident. time.Sleep(10 * time.Millisecond) return json.Marshal(parsed) }), "mutation %d must not leak a concurrent-update error", i) } reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) var counts map[string]int require.NoError(t, json.Unmarshal(c.Config, &counts)) assert.Equal(t, n, counts["failed_attempts"], "every increment must be applied exactly once") }) t.Run("case=UpdateCredentialsConfig no-op mutation succeeds", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"cas-noop-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, p.CreateIdentity(ctx, id)) // A mutation that leaves the config unchanged must succeed without // writing. require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { return cfg, nil })) reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) var counts map[string]int require.NoError(t, json.Unmarshal(c.Config, &counts)) assert.Equal(t, 0, counts["failed_attempts"], "config must be unchanged") }) t.Run("case=UpdateCredentialsConfig serializes the whole read-mutate-write", func(t *testing.T) { t.Parallel() if dbname == "sqlite" { // SQLite serializes writers at the engine level; the interleaving // this test forces cannot occur there. t.Skip("SQLite cannot interleave two writers") } _, p := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"lock-serialization-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, p.CreateIdentity(ctx, id)) // Writer A parks inside its mutate callback; writer B starts only once // A is inside. Under an exclusive row lock B cannot run until A // commits, so B must observe A's increment. Asserting on the value B // reads (not on wall-clock ordering) is causally determined by the // locking and immune to goroutine scheduling. var aInsideOnce, bInsideOnce sync.Once aInside := make(chan struct{}) bInside := make(chan struct{}) releaseA := make(chan struct{}) increment := func(cfg []byte) (map[string]int, error) { var parsed map[string]int if err := json.Unmarshal(cfg, &parsed); err != nil { return nil, err } parsed["failed_attempts"]++ return parsed, nil } var wg sync.WaitGroup wg.Go(func() { err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { // The callback may re-run on database retries, so the // signal fires once and the park is re-entrant. aInsideOnce.Do(func() { close(aInside) }) <-releaseA parsed, err := increment(cfg) if err != nil { return nil, err } return json.Marshal(parsed) }) assert.NoError(t, err) }) <-aInside // bSaw is written only from B's callback and read after wg.Wait. bSaw := -1 wg.Go(func() { err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { bInsideOnce.Do(func() { close(bInside) }) parsed, err := increment(cfg) if err != nil { return nil, err } bSaw = parsed["failed_attempts"] - 1 return json.Marshal(parsed) }) assert.NoError(t, err) }) // Give B time to reach the row before releasing A; if B's callback // runs during this window, the value assertion below fails. select { case <-bInside: case <-time.After(500 * time.Millisecond): } close(releaseA) wg.Wait() assert.Equal(t, 1, bSaw, "B's mutate must observe A's committed increment; observing the pre-A value means the read-mutate-write cycle is not exclusively locked") reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) var counts map[string]int require.NoError(t, json.Unmarshal(c.Config, &counts)) assert.Equal(t, 2, counts["failed_attempts"], "both increments must be committed") }) t.Run("case=UpdateCredentialsConfig runs at READ COMMITTED", func(t *testing.T) { t.Parallel() // UpdateCredentialsConfig depends on READ COMMITTED so that its // SELECT ... FOR UPDATE is a durable row lock on CockroachDB rather // than the best-effort lock SERIALIZABLE gives. This guards that the // isolation request is actually honored — not silently upgraded by // the cluster or dropped by a pop/driver change — because under // SERIALIZABLE the lock, and therefore the lost-update protection, // would quietly weaken. // // Only PostgreSQL and CockroachDB are asserted here. SQLite has no // configurable isolation and serializes writers at the engine level. // MySQL's default REPEATABLE READ already takes durable FOR UPDATE // locks (so RC is not needed for correctness there), and its // per-transaction isolation override does not update // @@transaction_isolation, so it cannot be observed with a query. if dbname != "postgres" && dbname != "cockroach" { t.Skipf("transaction isolation is not observable / not required on %s", dbname) } var got string require.NoError(t, popx.TransactionWithOptions(ctx, p.GetConnection(ctx), &sql.TxOptions{Isolation: sql.LevelReadCommitted}, func(ctx context.Context, tx *pop.Connection) error { var row struct { Level string `db:"transaction_isolation"` } if err := tx.RawQuery("SHOW transaction_isolation").First(&row); err != nil { return err } got = row.Level return nil })) assert.Equal(t, "read committed", got) }) t.Run("case=UpdateCredentialsConfig bounds the wait for the row lock", func(t *testing.T) { t.Parallel() if dbname == "sqlite" { t.Skip("SQLite has no row-lock wait to bound") } if testing.Short() { t.Skip("holds a row lock for longer than the lock timeout") } _, p := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"lock-timeout-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, p.CreateIdentity(ctx, id)) // Writer A parks inside its mutate callback holding the row lock. The // whole transaction runs under a bounded context, so neither the // holder nor a waiter can occupy a pooled connection beyond the // budget: A's transaction is rolled back when its budget expires, and // B — whether it errors on its own deadline or acquires the lock // freed by A's rollback — must return within the generous ceiling // (5x the 5s budget) instead of parking for an engine default (e.g. // MySQL's 50s innodb_lock_wait_timeout). Neither writer's outcome is // asserted: A deliberately overstays its budget, and B's result // depends on whose deadline fires first. var aInsideOnce sync.Once aInside := make(chan struct{}) releaseA := make(chan struct{}) var wg sync.WaitGroup wg.Go(func() { _ = p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { aInsideOnce.Do(func() { close(aInside) }) <-releaseA return cfg, nil }) }) <-aInside start := time.Now() _ = p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { return cfg, nil }) elapsed := time.Since(start) close(releaseA) wg.Wait() assert.Less(t, elapsed, 25*time.Second, "the wait must be bounded near the configured lock timeout, not the engine default") }) t.Run("case=UpdateCredentialsConfig refuses to run inside a surrounding transaction", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"lock-ambient-tx-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, p.CreateIdentity(ctx, id)) // An ambient transaction would extend the lock and its timeout to the // outer transaction's lifetime; the persister must fail loudly instead. mutated := false err := p.Transaction(ctx, func(ctx context.Context, _ *pop.Connection) error { return p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { mutated = true return cfg, nil }) }) require.Error(t, err, "calling UpdateCredentialsConfig inside a transaction must be rejected") assert.False(t, mutated, "mutate must not run when the call is rejected") }) t.Run("case=UpdateConfig typed adapter", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) // The adapter is credential-type agnostic; this stand-in models a // complete stored config shape (like a lockout or clone counter). type counterConfig struct { FailedAttempts int `json:"failed_attempts"` } newCounterIdentity := func(t *testing.T, rawConfig string) *identity.Identity { t.Helper() id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{x.NewUUID().String()}, Config: sqlxx.JSONRawMessage(rawConfig), }) require.NoError(t, p.CreateIdentity(ctx, id)) return id } t.Run("decodes, mutates, and persists", func(t *testing.T) { t.Parallel() id := newCounterIdentity(t, `{"failed_attempts":41}`) require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, identity.UpdateConfig(func(c *counterConfig) error { c.FailedAttempts++ return nil }))) reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) assert.Equal(t, int64(42), gjson.GetBytes(c.Config, "failed_attempts").Int()) }) t.Run("pins that fields T does not model are dropped", func(t *testing.T) { t.Parallel() // Pins the documented contract: T must model the COMPLETE stored // shape, because a partial T silently drops sibling fields. id := newCounterIdentity(t, `{"failed_attempts":5,"sibling":"not-modeled-by-T"}`) require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, identity.UpdateConfig(func(c *counterConfig) error { c.FailedAttempts++ return nil }))) reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) assert.Equal(t, int64(6), gjson.GetBytes(c.Config, "failed_attempts").Int()) assert.False(t, gjson.GetBytes(c.Config, "sibling").Exists(), "a field T does not model is dropped by the roundtrip — T must model the complete stored shape") }) t.Run("null config is rejected without calling mutate", func(t *testing.T) { t.Parallel() // Mutating a zero value would silently replace whatever the row // held. JSON null is the one undecodable state every dialect can // store (JSON-typed columns refuse empty/whitespace outright). id := newCounterIdentity(t, "null") mutated := false err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, identity.UpdateConfig(func(c *counterConfig) error { mutated = true return nil })) require.Error(t, err) assert.False(t, mutated, "mutate must not run on an undecodable config") }) t.Run("mutate error aborts without persisting", func(t *testing.T) { t.Parallel() id := newCounterIdentity(t, `{"failed_attempts":7}`) bang := errors.New("mutation rejected") err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, identity.UpdateConfig(func(c *counterConfig) error { c.FailedAttempts = 999 return bang })) require.ErrorIs(t, err, bang) reloaded, err := p.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) assert.Equal(t, int64(7), gjson.GetBytes(c.Config, "failed_attempts").Int(), "a failed mutation must not be persisted") }) }) t.Run("case=UpdateCredentialsConfig not if on another network", func(t *testing.T) { t.Parallel() // Create the identity (and its credential row) on network A. _, pA := testhelpers.NewNetwork(t, ctx, p) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: []string{"cas-nid-isolation-1"}, Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), }) require.NoError(t, pA.CreateIdentity(ctx, id)) // A persister scoped to a different network must not see the row. // The locked SELECT is keyed by nid, so it returns not-found and never // invokes mutate, guarding against a cross-tenant mutation. _, pB := testhelpers.NewNetwork(t, ctx, p) mutated := false err := pB.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { mutated = true return []byte(`{"failed_attempts":999}`), nil }) require.ErrorIs(t, err, sqlcon.ErrNoRows()) assert.False(t, mutated, "mutate must not run for an identity on another network") // The original row on network A must be untouched. reloaded, err := pA.GetIdentityConfidential(ctx, id.ID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) var counts map[string]int require.NoError(t, json.Unmarshal(c.Config, &counts)) assert.Equal(t, 0, counts["failed_attempts"], "row must not be mutated across networks") }) t.Run("case=UpdateCredentialsConfig syncs derived identifiers", func(t *testing.T) { t.Parallel() // The stand-in config stores a set of keys; the identifier rows are // pure derived state of that set. deriveKeys := func(cfg []byte) ([]string, error) { var parsed struct { Keys []string `json:"keys"` } if err := json.Unmarshal(cfg, &parsed); err != nil { return nil, err } return parsed.Keys, nil } setKeys := func(keys ...string) func(cfg []byte) ([]byte, error) { return func([]byte) ([]byte, error) { return json.Marshal(map[string][]string{"keys": keys}) } } newKeyedIdentity := func(t *testing.T, p persistence.Persister, identifiers ...string) *identity.Identity { t.Helper() cfg, err := json.Marshal(map[string][]string{"keys": identifiers}) require.NoError(t, err) id := identity.NewIdentity("default") id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ Type: identity.CredentialsTypeDeviceAuthn, Identifiers: identifiers, Config: sqlxx.JSONRawMessage(cfg), }) require.NoError(t, p.CreateIdentity(ctx, id)) return id } credentials := func(t *testing.T, p persistence.Persister, identityID uuid.UUID) identity.Credentials { t.Helper() reloaded, err := p.GetIdentityConfidential(ctx, identityID) require.NoError(t, err) c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) require.True(t, ok) return *c } // identifierRows reads the credential's raw identifier rows so tests // can observe row identity (a re-created row gets a new id). identifierRows := func(t *testing.T, p persistence.Persister, credID uuid.UUID) map[string]uuid.UUID { t.Helper() var rows []struct { ID uuid.UUID `db:"id"` Identifier string `db:"identifier"` } require.NoError(t, p.GetConnection(ctx).RawQuery( `SELECT id, identifier FROM identity_credential_identifiers WHERE identity_credential_id = ?`, credID).All(&rows)) out := make(map[string]uuid.UUID, len(rows)) for _, r := range rows { out[r.Identifier] = r.ID } return out } t.Run("inserts the derived set on first sync", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := newKeyedIdentity(t, p, "sync-seed-1") require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, setKeys("sync-key-a", "sync-key-b"), identity.WithDerivedIdentifiers(deriveKeys))) c := credentials(t, p, id.ID) assert.ElementsMatch(t, []string{"sync-key-a", "sync-key-b"}, c.Identifiers, "identifier rows must reflect the post-mutation config") assert.ElementsMatch(t, []string{"sync-key-a", "sync-key-b"}, gjsonStrings(c.Config, "keys")) }) t.Run("removing a key removes its identifier row", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := newKeyedIdentity(t, p, "rm-key-a", "rm-key-b") require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, setKeys("rm-key-a"), identity.WithDerivedIdentifiers(deriveKeys))) c := credentials(t, p, id.ID) assert.ElementsMatch(t, []string{"rm-key-a"}, c.Identifiers, "the deleted key's identifier row must be gone") }) t.Run("no-op config with unchanged identifiers writes nothing", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := newKeyedIdentity(t, p, "noop-key-1") before := identifierRows(t, p, credentials(t, p, id.ID).ID) require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, func(cfg []byte) ([]byte, error) { return cfg, nil }, identity.WithDerivedIdentifiers(deriveKeys))) c := credentials(t, p, id.ID) assert.ElementsMatch(t, []string{"noop-key-1"}, c.Identifiers) assert.Equal(t, before, identifierRows(t, p, c.ID), "an unchanged identifier set must not re-create the rows") }) t.Run("identifier rows are nid-scoped", func(t *testing.T) { t.Parallel() _, pA := testhelpers.NewNetwork(t, ctx, p) idA := newKeyedIdentity(t, pA, "nid-scope-key") rowsA := identifierRows(t, pA, credentials(t, pA, idA.ID).ID) // A persister on another network must not see A's credential row; // neither mutate nor derive may run. _, pB := testhelpers.NewNetwork(t, ctx, p) derived := false err := pB.UpdateCredentialsConfig(ctx, idA.ID, identity.CredentialsTypeDeviceAuthn, setKeys("nid-scope-clobber"), identity.WithDerivedIdentifiers(func(cfg []byte) ([]string, error) { derived = true return deriveKeys(cfg) })) require.ErrorIs(t, err, sqlcon.ErrNoRows()) assert.False(t, derived, "derive must not run for an identity on another network") // The identifier uniqueness is per network: B may claim the same // value A holds. idB := newKeyedIdentity(t, pB, "nid-scope-seed") require.NoError(t, pB.UpdateCredentialsConfig(ctx, idB.ID, identity.CredentialsTypeDeviceAuthn, setKeys("nid-scope-key"), identity.WithDerivedIdentifiers(deriveKeys))) assert.ElementsMatch(t, []string{"nid-scope-key"}, credentials(t, pB, idB.ID).Identifiers) // A's rows must be untouched, down to the row ids. assert.Equal(t, rowsA, identifierRows(t, pA, credentials(t, pA, idA.ID).ID), "another network's sync must not clobber the rows") }) t.Run("duplicate identifier on another identity fails with unique violation", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) newKeyedIdentity(t, p, "dup-owned") victim := newKeyedIdentity(t, p, "dup-seed") err := p.UpdateCredentialsConfig(ctx, victim.ID, identity.CredentialsTypeDeviceAuthn, setKeys("dup-owned"), identity.WithDerivedIdentifiers(deriveKeys)) require.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) // The transaction must roll back as a whole: neither the config // nor the identifier rows may change. c := credentials(t, p, victim.ID) assert.ElementsMatch(t, []string{"dup-seed"}, gjsonStrings(c.Config, "keys")) assert.ElementsMatch(t, []string{"dup-seed"}, c.Identifiers) }) t.Run("derive error aborts and rolls back the config write", func(t *testing.T) { t.Parallel() _, p := testhelpers.NewNetwork(t, ctx, p) id := newKeyedIdentity(t, p, "derive-err-seed") before := identifierRows(t, p, credentials(t, p, id.ID).ID) bang := errors.New("derive failed") err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, setKeys("derive-err-new"), identity.WithDerivedIdentifiers(func([]byte) ([]string, error) { return nil, bang })) require.ErrorIs(t, err, bang, "the derive error must surface to the caller") // The transaction must roll back as a whole: the config write // that already executed is undone and the identifier rows are // untouched, down to the row ids. c := credentials(t, p, id.ID) assert.ElementsMatch(t, []string{"derive-err-seed"}, gjsonStrings(c.Config, "keys"), "the config write must roll back with the derive error") assert.ElementsMatch(t, []string{"derive-err-seed"}, c.Identifiers) assert.Equal(t, before, identifierRows(t, p, c.ID)) }) }) } } // gjsonStrings returns the string array at path in the JSON document. func gjsonStrings(doc []byte, path string) []string { var out []string for _, v := range gjson.GetBytes(doc, path).Array() { out = append(out, v.String()) } return out } func NewTestIdentity(numAddresses int, prefix string, i int) *identity.Identity { var ( verifiableAddresses []identity.VerifiableAddress recoveryAddresses []identity.RecoveryAddress ) traits := struct { Emails []string `json:"emails"` Username string `json:"username"` }{} verificationStates := []identity.VerifiableAddressStatus{ identity.VerifiableAddressStatusPending, identity.VerifiableAddressStatusSent, identity.VerifiableAddressStatusCompleted, } for j := 0; j < numAddresses; j++ { email := fmt.Sprintf("%s-%d-%d@ory.sh", prefix, i, j) traits.Emails = append(traits.Emails, email) verifiableAddresses = append(verifiableAddresses, identity.VerifiableAddress{ Value: email, Via: identity.AddressTypeEmail, Verified: j%2 == 0, Status: verificationStates[j%len(verificationStates)], }) recoveryAddresses = append(recoveryAddresses, identity.RecoveryAddress{ Value: email, Via: identity.AddressTypeEmail, }) } traits.Username = traits.Emails[0] rawTraits, _ := json.Marshal(traits) id := &identity.Identity{ SchemaID: "multiple_emails", Traits: rawTraits, VerifiableAddresses: verifiableAddresses, RecoveryAddresses: recoveryAddresses, State: "active", } id.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{traits.Username}, Config: sqlxx.JSONRawMessage(`{}`), }) return id }