snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
606
selfservice/hook/verify_new_address_test.go
Normal file
606
selfservice/hook/verify_new_address_test.go
Normal file
@@ -0,0 +1,606 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/selfservice/hook"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/randx"
|
||||
"github.com/ory/x/sqlxx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func TestVerifyNewAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
setup := func(t *testing.T) (context.Context, *hook.VerifyNewAddress, *driver.RegistryDefault) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
ctx := testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_single_email.schema.json")
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.sh/")
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
|
||||
h := hook.NewVerifyNewAddress(reg)
|
||||
return ctx, h, reg
|
||||
}
|
||||
|
||||
newSettingsFlow := func(t *testing.T, i identity.Identity) *settings.Flow {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
return &settings.Flow{
|
||||
ID: x.NewUUID(),
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
IssuedAt: now,
|
||||
RequestURL: "http://foo.com/settings",
|
||||
IdentityID: i.ID,
|
||||
Identity: &i,
|
||||
Type: flow.TypeBrowser,
|
||||
State: flow.StateShowForm,
|
||||
UI: &container.Container{Method: "POST"},
|
||||
InternalContext: []byte("{}"),
|
||||
}
|
||||
}
|
||||
|
||||
verifyAllAddresses := func(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, i *identity.Identity) *identity.Identity {
|
||||
t.Helper()
|
||||
for idx := range i.VerifiableAddresses {
|
||||
i.VerifiableAddresses[idx].Verified = true
|
||||
verifiedAt := sqlxx.NullTime(time.Now())
|
||||
i.VerifiableAddresses[idx].VerifiedAt = &verifiedAt
|
||||
i.VerifiableAddresses[idx].Status = identity.VerifiableAddressStatusCompleted
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, &i.VerifiableAddresses[idx]))
|
||||
}
|
||||
var err error
|
||||
i, err = reg.PrivilegedIdentityPool().GetIdentity(ctx, i.ID, identity.ExpandDefault)
|
||||
require.NoError(t, err)
|
||||
return i
|
||||
}
|
||||
|
||||
createIdentity := func(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, email string, verified bool) *identity.Identity {
|
||||
t.Helper()
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.Traits = identity.Traits(`{"email":"` + email + `","name":"Test"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
||||
|
||||
if verified {
|
||||
i = verifyAllAddresses(t, ctx, reg, i)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
t.Run("case=no-op when address does not change", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
|
||||
original := createIdentity(t, ctx, reg, "old@example.com", true)
|
||||
f := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
proposed := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"old@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "old@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{ID: x.NewUUID(), Identity: original, AuthenticatedAt: time.Now()}
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.NoError(t, err, "hook should be a no-op when address did not change")
|
||||
})
|
||||
|
||||
t.Run("case=aborts flow when address changes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
|
||||
original := createIdentity(t, ctx, reg, "old@example.com", true)
|
||||
f := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
proposed := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"new@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "new@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: original,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
// Settings flow should have a ContinueWith entry pointing to the verification flow.
|
||||
require.NotEmpty(t, f.ContinueWith(), "settings flow should have ContinueWith items")
|
||||
cw := f.ContinueWith()[0]
|
||||
assert.IsType(t, &flow.ContinueWithVerificationUI{}, cw)
|
||||
vfID := cw.(*flow.ContinueWithVerificationUI).Flow.ID
|
||||
|
||||
// A PendingTraitsChange record should exist.
|
||||
ptc, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, identity.PendingTraitsChangeStatusPending, ptc.Status)
|
||||
assert.Equal(t, "new@example.com", ptc.NewAddressValue)
|
||||
assert.Equal(t, original.ID, ptc.IdentityID)
|
||||
assert.Equal(t, identity.HashTraits(json.RawMessage(original.Traits)), ptc.OriginalTraitsHash)
|
||||
require.True(t, ptc.SessionID.Valid, "pending change should record the session id")
|
||||
assert.Equal(t, sess.ID, ptc.SessionID.UUID)
|
||||
require.True(t, ptc.OriginSettingsFlowID.Valid, "pending traits change should record the originating settings flow ID")
|
||||
assert.Equal(t, f.ID, ptc.OriginSettingsFlowID.UUID, "originating settings flow ID should match the flow the hook ran in")
|
||||
|
||||
// Verification flow should exist and be in StateEmailSent.
|
||||
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, vfID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, flow.StateEmailSent, vf.State)
|
||||
|
||||
// A verification code message should have been sent.
|
||||
messages, err := reg.CourierPersister().NextMessages(ctx, 12)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
assert.Equal(t, "new@example.com", messages[0].Recipient)
|
||||
})
|
||||
|
||||
t.Run("case=previous pending changes are deleted on new submission", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
|
||||
original := createIdentity(t, ctx, reg, "old@example.com", true)
|
||||
|
||||
// First change: old@example.com -> new1@example.com
|
||||
f1 := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f1))
|
||||
|
||||
proposed1 := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"new1@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "new1@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: original,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f1,
|
||||
Identity: proposed1,
|
||||
Session: sess,
|
||||
})
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow))
|
||||
|
||||
vfID1 := f1.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
|
||||
ptc1, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new1@example.com", ptc1.NewAddressValue)
|
||||
|
||||
// Drain courier messages from first change.
|
||||
_, _ = reg.CourierPersister().NextMessages(ctx, 12)
|
||||
|
||||
// Second change: old@example.com -> new2@example.com
|
||||
f2 := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f2))
|
||||
|
||||
proposed2 := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"new2@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "new2@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
err = h.ExecuteSettingsPrePersistHook(w2, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f2,
|
||||
Identity: proposed2,
|
||||
Session: sess,
|
||||
})
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow))
|
||||
|
||||
vfID2 := f2.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
|
||||
ptc2, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new2@example.com", ptc2.NewAddressValue)
|
||||
|
||||
// The first pending change should have been deleted.
|
||||
_, err = reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID1)
|
||||
require.Error(t, err, "first pending change should have been deleted")
|
||||
})
|
||||
|
||||
t.Run("case=correctly detects change with two email addresses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_two_emails.schema.json")
|
||||
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.Traits = identity.Traits(`{"email":"primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
||||
|
||||
// Verify both addresses.
|
||||
i = verifyAllAddresses(t, ctx, reg, i)
|
||||
require.Len(t, i.VerifiableAddresses, 2, "identity should have two verifiable addresses")
|
||||
|
||||
// Change only the primary email; recovery email stays the same.
|
||||
f := newSettingsFlow(t, *i)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
proposed := &identity.Identity{
|
||||
ID: i.ID,
|
||||
Traits: identity.Traits(`{"email":"new-primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "new-primary@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
|
||||
{Value: "recovery@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: i,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
// Should abort with exactly one changed address (not two, not zero).
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
// Verify only one verification was triggered (for the changed address).
|
||||
assert.Len(t, f.ContinueWith(), 1)
|
||||
cw := f.ContinueWith()[0]
|
||||
assert.IsType(t, &flow.ContinueWithVerificationUI{}, cw)
|
||||
vfID := cw.(*flow.ContinueWithVerificationUI).Flow.ID
|
||||
|
||||
ptc, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-primary@example.com", ptc.NewAddressValue, "pending change should be for the changed address only")
|
||||
})
|
||||
|
||||
t.Run("case=no-op with two unchanged email addresses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_two_emails.schema.json")
|
||||
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.Traits = identity.Traits(`{"email":"primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
||||
|
||||
i = verifyAllAddresses(t, ctx, reg, i)
|
||||
|
||||
f := newSettingsFlow(t, *i)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
proposed := &identity.Identity{
|
||||
ID: i.ID,
|
||||
Traits: identity.Traits(`{"email":"primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "primary@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
|
||||
{Value: "recovery@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{ID: x.NewUUID(), Identity: i, AuthenticatedAt: time.Now()}
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.NoError(t, err, "hook should be a no-op when no addresses changed")
|
||||
assert.Empty(t, f.ContinueWith())
|
||||
})
|
||||
|
||||
t.Run("case=rejects change when new address is already owned by another identity", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
|
||||
_ = createIdentity(t, ctx, reg, "existing@example.com", true)
|
||||
|
||||
original := createIdentity(t, ctx, reg, "other@example.com", true)
|
||||
f := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
proposed := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"existing@example.com","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "existing@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: original,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
// Flow should carry the duplicate credentials error — no redirect to verification.
|
||||
assert.Empty(t, f.ContinueWith(), "should not start a verification flow")
|
||||
require.NotEmpty(t, f.UI.Messages)
|
||||
assert.Contains(t, f.UI.Messages[0].Text, "exists already")
|
||||
})
|
||||
|
||||
t.Run("case=rejects change when new phone number is already owned by another identity", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_email_and_phone.schema.json")
|
||||
|
||||
existing := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
existing.Traits = identity.Traits(`{"email":"phone-existing@example.com","phone":"+19999999999","name":"Existing"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, existing))
|
||||
verifyAllAddresses(t, ctx, reg, existing)
|
||||
|
||||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
original.Traits = identity.Traits(`{"email":"phone-original@example.com","phone":"+10000000000","name":"Original"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, original))
|
||||
original = verifyAllAddresses(t, ctx, reg, original)
|
||||
|
||||
f := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
// Proposed: email unchanged, phone changed to the existing's number.
|
||||
proposed := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"phone-original@example.com","phone":"+19999999999","name":"Original"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "phone-original@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
{Value: "+19999999999", Via: identity.AddressTypeSMS, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: original,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
// Flow should carry the duplicate credentials error — no redirect to verification.
|
||||
assert.Empty(t, f.ContinueWith(), "should not start a verification flow")
|
||||
require.NotEmpty(t, f.UI.Messages)
|
||||
assert.Contains(t, f.UI.Messages[0].Text, "exists already")
|
||||
})
|
||||
|
||||
t.Run("case=returns error when multiple addresses change at once", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_email_and_phone.schema.json")
|
||||
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.Traits = identity.Traits(`{"email":"old@example.com","phone":"+12345678901","name":"Test"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
||||
|
||||
i = verifyAllAddresses(t, ctx, reg, i)
|
||||
|
||||
f := newSettingsFlow(t, *i)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
proposed := &identity.Identity{
|
||||
ID: i.ID,
|
||||
Traits: identity.Traits(`{"email":"new@example.com","phone":"+19876543210","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "new@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
|
||||
{Value: "+19876543210", Via: "sms", IdentityID: i.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{ID: x.NewUUID(), Identity: i, AuthenticatedAt: time.Now()}
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
// The flow should contain the too-many-address-changes error message.
|
||||
require.NotEmpty(t, f.UI.Messages)
|
||||
})
|
||||
|
||||
t.Run("case=SMS verification flow shows the phone code-sent message, not the email one", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_email_and_phone.schema.json")
|
||||
// Configure an SMS courier channel so the verification code can be queued for a phone number.
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeyCourierChannels, []map[string]any{
|
||||
{"id": "sms", "type": "http", "request_config": map[string]any{
|
||||
"url": "http://localhost:1234/sms",
|
||||
"method": "POST",
|
||||
"body": "base64://ZnVuY3Rpb24oY3R4KSBjdHg=",
|
||||
}},
|
||||
})
|
||||
|
||||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
original.Traits = identity.Traits(`{"email":"sms-msg@example.com","phone":"+10000000000","name":"Test"}`)
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, original))
|
||||
original = verifyAllAddresses(t, ctx, reg, original)
|
||||
|
||||
f := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
// Proposed: email unchanged, phone changed to a new, unowned number.
|
||||
const newPhone = "+19876543210"
|
||||
proposed := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"sms-msg@example.com","phone":"` + newPhone + `","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: "sms-msg@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
{Value: newPhone, Via: identity.AddressTypeSMS, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: original,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
require.NotEmpty(t, f.ContinueWith(), "settings flow should redirect to verification")
|
||||
vfID := f.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
|
||||
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, vfID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// An SMS verification must show only the phone "code has been sent" message,
|
||||
// never the email wording.
|
||||
require.Len(t, vf.UI.Messages, 1, "expected exactly one code-sent message")
|
||||
assert.Equal(t, text.InfoSelfServiceVerificationPhoneWithCodeSent, vf.UI.Messages[0].ID)
|
||||
})
|
||||
|
||||
t.Run("case=link strategy verification flow does not claim a code was sent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, h, reg := setup(t)
|
||||
// Use the legacy link strategy as the primary verification method. It
|
||||
// delivers a link, not a code, and sets no "code sent" message.
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+".link.enabled", true)
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceVerificationUse, "link")
|
||||
|
||||
original := createIdentity(t, ctx, reg, "link-msg@example.com", true)
|
||||
|
||||
f := newSettingsFlow(t, *original)
|
||||
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
||||
|
||||
const newEmail = "link-msg-new@example.com"
|
||||
proposed := &identity.Identity{
|
||||
ID: original.ID,
|
||||
Traits: identity.Traits(`{"email":"` + newEmail + `","name":"Test"}`),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: newEmail, Via: identity.AddressTypeEmail, IdentityID: original.ID},
|
||||
},
|
||||
}
|
||||
|
||||
sess := &session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Identity: original,
|
||||
Token: randx.MustString(12, randx.AlphaLowerNum),
|
||||
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
|
||||
AuthenticatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
|
||||
Flow: f,
|
||||
Identity: proposed,
|
||||
Session: sess,
|
||||
})
|
||||
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
|
||||
|
||||
require.NotEmpty(t, f.ContinueWith(), "settings flow should redirect to verification")
|
||||
vfID := f.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
|
||||
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, vfID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A link was sent, not a code — the flow must not carry a "code has
|
||||
// been sent" message.
|
||||
require.Empty(t, vf.UI.Messages, "link strategy flows must not claim a code was sent")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user