// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package registration_test import ( "context" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/gofrs/uuid" "github.com/pkg/errors" "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/hydra" "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/registration" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/x/assertx" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" ) type opts struct { oauth2LoginChallenge string } func withLoginChallenge(challenge string) func(*opts) { return func(o *opts) { o.oauth2LoginChallenge = challenge } } func TestHandleError(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeySelfServiceRegistrationEnabled, true), configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")), ) reg.SetHydra(hydra.NewFake()) public, _ := testhelpers.NewKratosServer(t, reg) router := http.NewServeMux() ts := httptest.NewServer(router) t.Cleanup(ts.Close) testhelpers.NewRegistrationUIFlowEchoServer(t, reg) testhelpers.NewErrorTestServer(t, reg) h := reg.RegistrationFlowErrorHandler() sdk := testhelpers.NewSDKClient(public) var registrationFlow *registration.Flow var flowError error var group node.UiNodeGroup router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) { h.WriteFlowError(w, r, registrationFlow, "", group, flowError) }) reset := func() { registrationFlow = nil flowError = nil group = "" } newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type, options ...func(*opts)) *registration.Flow { req := &http.Request{URL: urlx.ParseOrPanic("/")} f, err := registration.NewFlow(reg, req, ft) require.NoError(t, err) opts := &opts{} for _, o := range options { o(opts) } if opts.oauth2LoginChallenge != "" { f.OAuth2LoginChallenge = sqlxx.NullString(opts.oauth2LoginChallenge) } for _, s := range reg.RegistrationStrategies(context.Background()) { var populateErr error switch strategy := s.(type) { case registration.FormHydrator: switch { case conf.SelfServiceFlowRegistrationTwoSteps(ctx): populateErr = strategy.PopulateRegistrationMethodProfile(req, f) default: populateErr = strategy.PopulateRegistrationMethod(req, f) } case registration.UnifiedFormHydrator: populateErr = strategy.PopulateRegistrationMethod(req, f) default: populateErr = errors.WithStack(x.PseudoPanic.WithReasonf("A registration strategy was expected to implement one of the interfaces UnifiedFormHydrator or FormHydrator but did not.")) } require.NoError(t, populateErr) } require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(context.Background(), f)) return f } expectErrorUI := func(t *testing.T) (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") group = node.PasswordGroup 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") group = node.PasswordGroup 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) registrationFlow = newFlow(t, time.Minute, tc.t) flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) group = node.PasswordGroup res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusGone, res.StatusCode, "%+v\n\t%s", res.Request, body) assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.GetBytes(body, "use_flow_id").String()) assert.Equal(t, text.ErrIDSelfServiceFlowExpired, gjson.GetBytes(body, "error.id").String(), "%s", body) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) registrationFlow = newFlow(t, time.Minute, tc.t) flowError = schema.NewInvalidCredentialsError() group = node.PasswordGroup 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, registrationFlow.ID.String(), gjson.GetBytes(body, "id").String()) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) registrationFlow = newFlow(t, time.Minute, tc.t) flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup 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=refuses to parse oauth2 login challenge when Hydra is not configured", func(t *testing.T) { t.Cleanup(reset) registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeInvalidLoginChallenge)) group = node.PasswordGroup flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error") res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body) require.Equal(t, "Unable to get OAuth 2.0 Login Challenge.", gjson.GetBytes(body, "error.reason").String(), "%s", body) }) t.Run("case=fetches hydra login request", func(t *testing.T) { t.Cleanup(reset) conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra") t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, nil) }) registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeValidLoginChallenge)) group = node.PasswordGroup flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error") res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body) require.NotEmpty(t, gjson.GetBytes(body, "oauth2_login_request").Value(), "%s", body) }) }) } t.Run("flow=browser", func(t *testing.T) { expectRegistrationUI := func(t *testing.T) (*registration.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.SelfServiceFlowRegistrationUI(ctx).String()+"?flow=") rf, err := reg.RegistrationFlowPersister().GetRegistrationFlow(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) registrationFlow = ®istration.Flow{Type: flow.TypeBrowser} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) group = node.PasswordGroup lf, _ := expectRegistrationUI(t) require.Len(t, lf.UI.Messages, 1) assert.Equal(t, int(text.ErrorValidationRegistrationFlowExpired), int(lf.UI.Messages[0].ID)) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = schema.NewInvalidCredentialsError() group = node.PasswordGroup lf, _ := expectRegistrationUI(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) registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) }) }