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:
289
selfservice/flow/settings/hook_pending_traits_change_test.go
Normal file
289
selfservice/flow/settings/hook_pending_traits_change_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user