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
607 lines
24 KiB
Go
607 lines
24 KiB
Go
// 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")
|
|
})
|
|
}
|