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
290 lines
10 KiB
Go
290 lines
10 KiB
Go
// Copyright © 2026 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package settings_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"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/flow/verification"
|
|
"github.com/ory/kratos/session"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/kratos/x/nosurfx"
|
|
)
|
|
|
|
// applyPTCFixture holds a wired-up registry plus a minimal set of persisted
|
|
// objects (identity, session, settings flow, verification flow, PTC) for
|
|
// unit-testing HookExecutor.ApplyPendingTraitsChange. Subtests mutate the
|
|
// PTC before calling ApplyPendingTraitsChange to exercise specific guard paths.
|
|
type applyPTCFixture struct {
|
|
reg *driver.RegistryDefault
|
|
identity *identity.Identity
|
|
sess *session.Session
|
|
flow *settings.Flow
|
|
ptc *identity.PendingTraitsChange
|
|
}
|
|
|
|
// newApplyPTCFixture constructs a fresh, isolated fixture for each subtest.
|
|
// All objects are persisted to the SQLite test database.
|
|
func newApplyPTCFixture(t *testing.T) *applyPTCFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
|
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
|
|
|
|
// Create an identity whose traits will be the baseline for the PTC.
|
|
i := &identity.Identity{
|
|
ID: x.NewUUID(),
|
|
Traits: identity.Traits(`{"email":"original@example.com"}`),
|
|
SchemaID: "default",
|
|
State: identity.StateActive,
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i, identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
// Create an active session linked to the identity.
|
|
sess, err := testhelpers.NewActiveSession(
|
|
httptest.NewRequest(http.MethodGet, "/", nil),
|
|
reg, i, time.Now().UTC(),
|
|
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
|
|
|
// Create a settings flow that acts as the origin for the PTC.
|
|
f := &settings.Flow{
|
|
ID: x.NewUUID(),
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
IssuedAt: time.Now().UTC(),
|
|
RequestURL: "http://localhost/settings",
|
|
IdentityID: i.ID,
|
|
Identity: i,
|
|
Type: flow.TypeBrowser,
|
|
State: flow.StateShowForm,
|
|
InternalContext: []byte("{}"),
|
|
}
|
|
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
|
|
|
|
// Create a verification flow — the PTC table has a FK on verification_flow_id
|
|
// (ON DELETE CASCADE), so we must persist a real verification flow row.
|
|
vf, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
|
|
httptest.NewRequest(http.MethodGet, "/verification", nil), nil, flow.TypeBrowser)
|
|
|
|
require.NoError(t, err)
|
|
vf.State = flow.StateEmailSent
|
|
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, vf))
|
|
|
|
// Build a PTC that is valid in every field; subtests nil out individual
|
|
// fields to trigger the specific guard under test.
|
|
sessID := sess.ID
|
|
originFlowID := f.ID
|
|
ptc := &identity.PendingTraitsChange{
|
|
ID: x.NewUUID(),
|
|
IdentityID: i.ID,
|
|
NewAddressValue: "new@example.com",
|
|
NewAddressVia: string(identity.AddressTypeEmail),
|
|
OriginalTraitsHash: identity.HashTraits(json.RawMessage(i.Traits)),
|
|
ProposedTraits: json.RawMessage(`{"email":"new@example.com"}`),
|
|
VerificationFlowID: vf.ID,
|
|
Status: identity.PendingTraitsChangeStatusPending,
|
|
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
|
|
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowID, Valid: true},
|
|
}
|
|
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
|
|
|
|
return &applyPTCFixture{
|
|
reg: reg,
|
|
identity: i,
|
|
sess: sess,
|
|
flow: f,
|
|
ptc: ptc,
|
|
}
|
|
}
|
|
|
|
func TestApplyPendingTraitsChange_NilOriginSettingsFlowID(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newApplyPTCFixture(t)
|
|
|
|
// Simulate a PTC whose OriginSettingsFlowID was never set (malformed writer)
|
|
// or was cleared before the apply call.
|
|
fx.ptc.OriginSettingsFlowID = uuid.NullUUID{}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
_, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, settings.ErrPendingTraitsChangeSessionInvalid)
|
|
}
|
|
|
|
// Ensure the fixture itself is valid: a fully-populated PTC must not be
|
|
// rejected. This guards against regressions in the fixture setup.
|
|
func TestApplyPendingTraitsChange_ValidPTC(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newApplyPTCFixture(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
updated, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, updated)
|
|
}
|
|
|
|
func TestApplyPendingTraitsChange_NilSessionID(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newApplyPTCFixture(t)
|
|
|
|
fx.ptc.SessionID = uuid.NullUUID{}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
_, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, settings.ErrPendingTraitsChangeSessionInvalid)
|
|
}
|
|
|
|
func TestApplyPendingTraitsChange_NIDMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newApplyPTCFixture(t)
|
|
|
|
// Simulate a cross-tenant PTC: the PTC's NID differs from the session's NID.
|
|
fx.ptc.NID = uuid.Must(uuid.NewV4())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
_, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, settings.ErrPendingTraitsChangeSessionInvalid)
|
|
}
|
|
|
|
// TestApplyPendingTraitsChange_WebhookReceivesOriginFlow asserts that the
|
|
// post-persist hook chain is invoked with the originating settings flow
|
|
// (not a synthetic one), so webhook payloads carry the real flow ID,
|
|
// type, and request URL.
|
|
func TestApplyPendingTraitsChange_WebhookReceivesOriginFlow(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
|
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
|
|
|
|
// Set up a recording webhook target that captures the raw body.
|
|
receivedCh := make(chan []byte, 1)
|
|
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
select {
|
|
case receivedCh <- body:
|
|
default:
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(webhookServer.Close)
|
|
|
|
// Register a web_hook as a settings-profile post-persist hook.
|
|
// The jsonnet body template `function(ctx) ctx` forwards the entire context,
|
|
// which includes the flow object with its ID, type, and request_url.
|
|
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, "profile"), []map[string]any{
|
|
{
|
|
"hook": "web_hook",
|
|
"config": map[string]any{
|
|
"url": webhookServer.URL,
|
|
"method": "POST",
|
|
"body": "base64://" + base64.StdEncoding.EncodeToString([]byte(`function(ctx) ctx`)),
|
|
},
|
|
},
|
|
})
|
|
t.Cleanup(func() {
|
|
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, "profile"), []map[string]any{})
|
|
})
|
|
|
|
i := &identity.Identity{
|
|
ID: x.NewUUID(),
|
|
Traits: identity.Traits(`{"email":"webhooktest@example.com"}`),
|
|
SchemaID: "default",
|
|
State: identity.StateActive,
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i, identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
sess, err := testhelpers.NewActiveSession(
|
|
httptest.NewRequest(http.MethodGet, "/", nil),
|
|
reg, i, time.Now().UTC(),
|
|
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
|
|
|
originFlow := &settings.Flow{
|
|
ID: x.NewUUID(),
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
IssuedAt: time.Now().UTC(),
|
|
RequestURL: "http://localhost/settings?origin=real",
|
|
IdentityID: i.ID,
|
|
Identity: i,
|
|
Type: flow.TypeAPI,
|
|
State: flow.StateShowForm,
|
|
InternalContext: []byte("{}"),
|
|
}
|
|
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, originFlow))
|
|
|
|
vf, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
|
|
httptest.NewRequest(http.MethodGet, "/verification", nil), nil, flow.TypeBrowser)
|
|
|
|
require.NoError(t, err)
|
|
vf.State = flow.StateEmailSent
|
|
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, vf))
|
|
|
|
sessID := sess.ID
|
|
originFlowID := originFlow.ID
|
|
ptc := &identity.PendingTraitsChange{
|
|
ID: x.NewUUID(),
|
|
IdentityID: i.ID,
|
|
NewAddressValue: "webhooktest-new@example.com",
|
|
NewAddressVia: string(identity.AddressTypeEmail),
|
|
OriginalTraitsHash: identity.HashTraits(json.RawMessage(i.Traits)),
|
|
ProposedTraits: json.RawMessage(`{"email":"webhooktest-new@example.com"}`),
|
|
VerificationFlowID: vf.ID,
|
|
Status: identity.PendingTraitsChangeStatusPending,
|
|
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
|
|
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowID, Valid: true},
|
|
}
|
|
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
|
|
rw := httptest.NewRecorder()
|
|
_, err = reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, ptc)
|
|
require.NoError(t, err)
|
|
|
|
var receivedBody []byte
|
|
select {
|
|
case receivedBody = <-receivedCh:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("settings post-persist webhook was not invoked after pending traits change applied")
|
|
}
|
|
|
|
// The webhook payload should carry the real origin settings flow's ID and
|
|
// request URL — not values from a synthetic flow.
|
|
assert.Contains(t, string(receivedBody), originFlow.ID.String(),
|
|
"webhook payload should contain the real origin settings flow ID")
|
|
assert.Contains(t, string(receivedBody), "http://localhost/settings?origin=real",
|
|
"webhook payload should contain the real origin settings flow RequestURL")
|
|
}
|