Files
upstream-mirror 6083b5e184
Some checks failed
CI / Run tests and lints (push) Failing after 1s
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 1s
CI / Build CLI docs (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 2s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 1s
Docker Image Scanners / scanners (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CodeQL / Analyze (go) (push) Failing after 1s
CodeQL / Analyze (javascript) (push) Failing after 1s
Close Stale Issues / stale (push) Has been skipped
Generate and Publish Milestone Document / Generate and Publish Milestone Document (push) Has been skipped
Closed Reference Notifier / Find closed references (push) Has been skipped
top-up: 5 source files 128-600 KB
2026-09-16 00:27:14 +05:00

4307 lines
190 KiB
Go

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