// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package recovery_test import ( "context" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/x/clock" "github.com/ory/x/configx" "github.com/ory/herodot" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/pkg" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/kratos/x/nosurfx" "github.com/ory/x/assertx" "github.com/ory/x/ioutilx" "github.com/ory/x/jsonx" "github.com/ory/x/snapshotx" "github.com/ory/x/urlx" ) func TestHandleError(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{ config.ViperKeySelfServiceRecoveryEnabled: true, config.ViperKeySelfServiceRecoveryUse: "code", }), ) public, _ := testhelpers.NewKratosServer(t, reg) router := http.NewServeMux() ts := httptest.NewServer(router) t.Cleanup(ts.Close) testhelpers.NewRecoveryUIFlowEchoServer(t, reg) testhelpers.NewErrorTestServer(t, reg) h := reg.RecoveryFlowErrorHandler() sdk := testhelpers.NewSDKClient(public) var recoveryFlow *recovery.Flow var flowError error var methodName node.UiNodeGroup router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) { h.WriteFlowError(w, r, recoveryFlow, methodName, flowError) }) reset := func() { recoveryFlow = nil flowError = nil methodName = "" } newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow { req := &http.Request{URL: urlx.ParseOrPanic("/")} s, _, err := reg.GetActiveRecoveryStrategies(context.Background()) require.NoError(t, err) f, err := recovery.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, s, ft) require.NoError(t, err) require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f)) f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID) require.NoError(t, err) return f } expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) { res, err := ts.Client().Get(ts.URL + "/error") require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute() require.NoError(t, err) return sse.Error, nil } anHourAgo := time.Now().Add(-time.Hour) t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) { t.Cleanup(reset) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) t.Run("case=error with nil flow detects application/json", func(t *testing.T) { t.Cleanup(reset) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() assert.Contains(t, res.Header.Get("Content-Type"), "application/json") assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Contains(t, string(body), "system error") }) for _, tc := range []struct { n string t flow.Type }{ {"api", flow.TypeAPI}, {"spa", flow.TypeBrowser}, } { t.Run("flow="+tc.n, func(t *testing.T) { t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow) require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body)) assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = schema.NewInvalidCredentialsError() methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusBadRequest, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body) assert.Equal(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusInternalServerError, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw) }) t.Run("case=fails if active strategy is disabled", func(t *testing.T) { c, reg := pkg.NewVeryFastRegistryWithoutDB(t) require.NoError(t, c.Set(context.Background(), "selfservice.methods.code.enabled", false)) require.NoError(t, c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code")) _, _, err := reg.GetActiveRecoveryStrategies(context.Background()) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = err methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusBadRequest, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value")) }) }) } t.Run("flow=browser", func(t *testing.T) { expectRecoveryUI := func(t *testing.T) (*recovery.Flow, *http.Response) { res, err := ts.Client().Get(ts.URL + "/error") require.NoError(t, err) defer func() { _ = res.Body.Close() }() assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=") rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow"))) require.NoError(t, err) return rf, res } t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) methodName = node.LinkGroup lf, _ := expectRecoveryUI(t) require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = schema.NewInvalidCredentialsError() methodName = node.LinkGroup lf, _ := expectRecoveryUI(t) require.NotEmpty(t, lf.UI, x.MustEncodeJSON(t, lf)) require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf)) assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf)) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.LinkGroup sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) t.Run("case=new flow uses strategy of old flow", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) lf, _ := expectRecoveryUI(t) require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String()) }) t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) lf, _ := expectRecoveryUI(t) require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) assert.Equal(t, "code", lf.Active.String()) }) t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) { t.Cleanup(func() { reset() conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code") }) recoveryFlow = newFlow(t, 0, flow.TypeBrowser) recoveryFlow.Active = "not-valid" flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid") sse, _ := expectErrorUI(t) snapshotx.SnapshotT(t, sse) }) }) } func TestHandleError_WithContinueWith(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{ config.ViperKeyUseContinueWithTransitions: true, config.ViperKeySelfServiceRecoveryEnabled: true, config.ViperKeySelfServiceRecoveryUse: "code", }), ) public, _ := testhelpers.NewKratosServer(t, reg) router := http.NewServeMux() ts := httptest.NewServer(router) t.Cleanup(ts.Close) testhelpers.NewRecoveryUIFlowEchoServer(t, reg) testhelpers.NewErrorTestServer(t, reg) h := reg.RecoveryFlowErrorHandler() sdk := testhelpers.NewSDKClient(public) var recoveryFlow *recovery.Flow var flowError error var methodName node.UiNodeGroup router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) { h.WriteFlowError(w, r, recoveryFlow, methodName, flowError) }) reset := func() { recoveryFlow = nil flowError = nil methodName = "" } newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow { req := &http.Request{URL: urlx.ParseOrPanic("/")} s, _, err := reg.GetActiveRecoveryStrategies(context.Background()) require.NoError(t, err) f, err := recovery.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, s, ft) require.NoError(t, err) require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f)) f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID) require.NoError(t, err) return f } expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) { res, err := ts.Client().Get(ts.URL + "/error") require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute() require.NoError(t, err) return sse.Error, nil } anHourAgo := time.Now().Add(-time.Hour) t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) { t.Cleanup(reset) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) t.Run("case=error with nil flow detects application/json", func(t *testing.T) { t.Cleanup(reset) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() assert.Contains(t, res.Header.Get("Content-Type"), "application/json") assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Contains(t, string(body), "system error") }) for _, tc := range []struct { n string t flow.Type }{ {"api", flow.TypeAPI}, {"spa", flow.TypeBrowser}, } { t.Run("flow="+tc.n, func(t *testing.T) { t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) body := ioutilx.MustReadAll(res.Body) switch tc.t { case flow.TypeAPI: require.Equal(t, http.StatusGone, res.StatusCode, "%s", body) require.Len(t, gjson.GetBytes(body, "error.details.continue_with").Array(), 1, "%s", body) require.Equal(t, "show_recovery_ui", gjson.GetBytes(body, "error.details.continue_with.0.action").String(), "%s", body) id := gjson.GetBytes(body, "error.details.continue_with.0.flow.id").String() res, err = ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, public.URL+recovery.RouteGetFlow+"?id="+id)) require.NoError(t, err) body = ioutilx.MustReadAll(res.Body) case flow.TypeBrowser: require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow, "%s", body) require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request) } assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body)) assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = schema.NewInvalidCredentialsError() methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusBadRequest, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body) assert.Equal(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusInternalServerError, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw) }) t.Run("case=fails if active strategy is disabled", func(t *testing.T) { c, reg := pkg.NewVeryFastRegistryWithoutDB(t) require.NoError(t, c.Set(context.Background(), "selfservice.methods.code.enabled", false)) require.NoError(t, c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code")) _, _, err := reg.GetActiveRecoveryStrategies(context.Background()) recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = err methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusBadRequest, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value")) }) }) } t.Run("flow=browser", func(t *testing.T) { expectRecoveryUI := func(t *testing.T) (*recovery.Flow, *http.Response) { res, err := ts.Client().Get(ts.URL + "/error") require.NoError(t, err) defer func() { _ = res.Body.Close() }() assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=") rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow"))) require.NoError(t, err) return rf, res } t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) methodName = node.LinkGroup lf, _ := expectRecoveryUI(t) require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = schema.NewInvalidCredentialsError() methodName = node.LinkGroup lf, _ := expectRecoveryUI(t) require.NotEmpty(t, lf.UI, x.MustEncodeJSON(t, lf)) require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf)) assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf)) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = herodot.ErrInternalServerError().WithReason("system error") methodName = node.LinkGroup sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) t.Run("case=new flow uses strategy of old flow", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) lf, _ := expectRecoveryUI(t) require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String()) }) t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) { t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) lf, _ := expectRecoveryUI(t) require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) assert.Equal(t, "code", lf.Active.String()) }) t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) { t.Cleanup(func() { reset() conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code") }) recoveryFlow = newFlow(t, 0, flow.TypeBrowser) recoveryFlow.Active = "not-valid" flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid") sse, _ := expectErrorUI(t) snapshotx.SnapshotT(t, sse) }) }) }