diff --git a/selfservice/strategy/code/strategy.go b/selfservice/strategy/code/strategy.go index 6044db79de0f..678a2bb5d6a2 100644 --- a/selfservice/strategy/code/strategy.go +++ b/selfservice/strategy/code/strategy.go @@ -150,10 +150,7 @@ func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error { } else if f.GetFlowName() == flow.LoginFlow { // we use the identifier label here since we don't know what // type of field the identifier is - nodes.Upsert( - node.NewInputField("identifier", nil, node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). - WithMetaLabel(text.NewInfoNodeLabelID()), - ) + nodes.Upsert(node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeLabelID())) } else if f.GetFlowName() == flow.RegistrationFlow { ds, err := s.deps.Config().DefaultIdentityTraitsSchemaURL(r.Context()) if err != nil { @@ -221,6 +218,15 @@ func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error { // so we can retry the code flow with the same data for _, n := range f.GetUI().Nodes { if n.Group == node.DefaultGroup { + // we don't need the user to change the values here + // for better UX let's make them disabled + // when there are errors we won't hide the fields + if len(n.Messages) == 0 { + if input, ok := n.Attributes.(*node.InputAttributes); ok { + input.Type = "hidden" + n.Attributes = input + } + } freshNodes = append(freshNodes, n) } } @@ -241,6 +247,15 @@ func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error { } if n.Group == node.DefaultGroup { + // we don't need the user to change the values here + // for better UX let's make them disabled + // when there are errors we won't hide the fields + if len(n.Messages) == 0 { + if input, ok := n.Attributes.(*node.InputAttributes); ok { + input.Type = "hidden" + n.Attributes = input + } + } freshNodes = append(freshNodes, n) } } diff --git a/selfservice/strategy/code/strategy_login_test.go b/selfservice/strategy/code/strategy_login_test.go index fac277d0d68a..f327a36e961a 100644 --- a/selfservice/strategy/code/strategy_login_test.go +++ b/selfservice/strategy/code/strategy_login_test.go @@ -21,6 +21,7 @@ import ( "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" + oryClient "github.com/ory/kratos/internal/httpclient" "github.com/ory/kratos/internal/testhelpers" "github.com/ory/kratos/session" "github.com/ory/x/sqlxx" @@ -82,23 +83,47 @@ func TestLoginCodeStrategy(t *testing.T) { testServer *httptest.Server } - createLoginFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, isSPA bool, moreIdentifiers ...string) *state { + type ApiType string + + const ( + ApiTypeBrowser ApiType = "browser" + ApiTypeSPA ApiType = "spa" + ApiTypeNative ApiType = "api" + ) + + createLoginFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, moreIdentifiers ...string) *state { t.Helper() identity := createIdentity(ctx, t, moreIdentifiers...) - client := testhelpers.NewClientWithCookies(t) + var client *http.Client + if apiType == ApiTypeNative { + client = &http.Client{} + } else { + client = testhelpers.NewClientWithCookies(t) + } + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper - clientInit := testhelpers.InitializeLoginFlowViaBrowser(t, client, public, false, isSPA, false, false) + + var clientInit *oryClient.LoginFlow + if apiType == ApiTypeNative { + clientInit = testhelpers.InitializeLoginFlowViaAPI(t, client, public, false) + } else { + clientInit = testhelpers.InitializeLoginFlowViaBrowser(t, client, public, false, apiType == ApiTypeSPA, false, false) + } body, err := json.Marshal(clientInit) require.NoError(t, err) csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmpty(t, csrfToken) + if apiType == ApiTypeNative { + require.Emptyf(t, csrfToken, "csrf_token should be empty in native flows, but was found in: %s", body) + } else { + require.NotEmptyf(t, csrfToken, "could not find csrf_token in: %s", body) + } loginEmail := gjson.Get(identity.Traits.String(), "email").String() - require.NotEmpty(t, loginEmail) + require.NotEmptyf(t, loginEmail, "could not find the email trait inside the identity: %s", identity.Traits.String()) return &state{ flowID: clientInit.GetId(), @@ -111,7 +136,7 @@ func TestLoginCodeStrategy(t *testing.T) { type onSubmitAssertion func(t *testing.T, s *state, body string, res *http.Response) - submitLogin := func(ctx context.Context, t *testing.T, s *state, isSPA bool, vals func(v *url.Values), mustHaveSession bool, submitAssertion onSubmitAssertion) *state { + submitLogin := func(ctx context.Context, t *testing.T, s *state, apiType ApiType, vals func(v *url.Values), mustHaveSession bool, submitAssertion onSubmitAssertion) *state { t.Helper() lf, resp, err := testhelpers.NewSDKCustomClient(s.testServer, s.client).FrontendApi.GetLoginFlow(ctx).Id(s.flowID).Execute() @@ -126,7 +151,7 @@ func TestLoginCodeStrategy(t *testing.T) { values.Set("method", "code") vals(&values) - body, resp := testhelpers.LoginMakeRequest(t, false, isSPA, lf, s.client, testhelpers.EncodeFormAsJSON(t, false, values)) + body, resp := testhelpers.LoginMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, lf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) if submitAssertion != nil { submitAssertion(t, s, body, resp) @@ -134,16 +159,23 @@ func TestLoginCodeStrategy(t *testing.T) { } if mustHaveSession { - resp, err = s.client.Get(s.testServer.URL + session.RouteWhoami) + req, err := http.NewRequest("GET", s.testServer.URL+session.RouteWhoami, nil) + require.NoError(t, err) + + if apiType == ApiTypeNative { + req.Header.Set("Authorization", "Bearer "+gjson.Get(body, "session_token").String()) + } + + resp, err = s.client.Do(req) require.NoError(t, err) require.EqualValues(t, http.StatusOK, resp.StatusCode) } else { // SPAs need to be informed that the login has not yet completed using status 400. // Browser clients will redirect back to the login URL. - if isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - } else { + if apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } } @@ -151,25 +183,29 @@ func TestLoginCodeStrategy(t *testing.T) { } for _, tc := range []struct { - d string - isSPA bool + d string + apiType ApiType }{ { - d: "SPA client", - isSPA: true, + d: "SPA client", + apiType: ApiTypeSPA, }, { - d: "Browser client", - isSPA: false, + d: "Browser client", + apiType: ApiTypeBrowser, + }, + { + d: "Native client", + apiType: ApiTypeNative, }, } { t.Run("test="+tc.d, func(t *testing.T) { t.Run("case=email identifier should be case insensitive", func(t *testing.T) { // create login flow - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", stringsx.ToUpperInitial(s.identityEmail)) }, false, nil) @@ -180,17 +216,17 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode) // 3. Submit OTP - submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) }, true, nil) }) t.Run("case=should be able to log in with code", func(t *testing.T) { // create login flow - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -201,17 +237,17 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode) // 3. Submit OTP - submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) }, true, nil) }) t.Run("case=should not be able to change submitted id on code submit", func(t *testing.T) { // create login flow - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -222,38 +258,34 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode) // 3. Submit OTP - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", "not-"+s.identityEmail) v.Set("code", loginCode) }, false, func(t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "account does not exist or has not setup sign in with code") - } else { + if tc.apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) require.EqualValues(t, conf.SelfServiceFlowLoginUI(ctx).Path, resp.Request.URL.Path) - lf, resp, err := testhelpers.NewSDKCustomClient(public, s.client).FrontendApi.GetLoginFlow(ctx).Id(s.flowID).Execute() require.NoError(t, err) require.EqualValues(t, http.StatusOK, resp.StatusCode) body, err := json.Marshal(lf) require.NoError(t, err) assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "account does not exist or has not setup sign in with code") + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) + assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "account does not exist or has not setup sign in with code") } }) }) t.Run("case=should not be able to proceed to code entry when the account is unknown", func(t *testing.T) { - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", testhelpers.RandomEmail()) }, false, func(t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "account does not exist or has not setup sign in with code") - } else { + if tc.apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) require.EqualValues(t, conf.SelfServiceFlowLoginUI(ctx).Path, resp.Request.URL.Path) @@ -263,15 +295,18 @@ func TestLoginCodeStrategy(t *testing.T) { body, err := json.Marshal(lf) require.NoError(t, err) assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "account does not exist or has not setup sign in with code") + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) + assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "account does not exist or has not setup sign in with code") } }) }) t.Run("case=should not be able to use valid code after 5 attempts", func(t *testing.T) { - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -282,30 +317,30 @@ func TestLoginCodeStrategy(t *testing.T) { for i := 0; i < 5; i++ { // 3. Submit OTP - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", "111111") v.Set("identifier", s.identityEmail) }, false, func(t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - } else { + if tc.apiType == ApiTypeBrowser { // in browser flows we redirect back to the login ui require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The login code is invalid or has already been used") }) } // 3. Submit OTP - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) v.Set("identifier", s.identityEmail) }, false, func(t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - } else { + if tc.apiType == ApiTypeBrowser { // in browser flows we redirect back to the login ui require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The request was submitted too often.") }) @@ -320,10 +355,10 @@ func TestLoginCodeStrategy(t *testing.T) { conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.config.lifespan", "1h") }) - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -332,14 +367,11 @@ func TestLoginCodeStrategy(t *testing.T) { loginCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, loginCode) - submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) v.Set("identifier", s.identityEmail) }, false, func(t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusGone, resp.StatusCode) - require.Contains(t, gjson.Get(body, "error.reason").String(), "self-service flow expired 0.00 minutes ago") - } else { + if tc.apiType == ApiTypeBrowser { // with browser clients we redirect back to the UI with a new flow id as a query parameter require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, conf.SelfServiceFlowLoginUI(ctx).Path, resp.Request.URL.Path) @@ -350,6 +382,9 @@ func TestLoginCodeStrategy(t *testing.T) { body, err := json.Marshal(lf) require.NoError(t, err) assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "flow expired 0.00 minutes ago") + } else { + require.EqualValues(t, http.StatusGone, resp.StatusCode) + require.Contains(t, gjson.Get(body, "error.reason").String(), "self-service flow expired 0.00 minutes ago") } }) }) @@ -357,9 +392,9 @@ func TestLoginCodeStrategy(t *testing.T) { t.Run("case=resend code should invalidate previous code", func(t *testing.T) { ctx := context.Background() - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -369,7 +404,7 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode) // resend code - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("resend", "code") v.Set("identifier", s.identityEmail) }, false, nil) @@ -380,26 +415,26 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode2) assert.NotEqual(t, loginCode, loginCode2) - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) v.Set("identifier", s.identityEmail) }, false, func(t *testing.T, s *state, body string, res *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, res.StatusCode) - } else { + if tc.apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, res.StatusCode) + } else { + require.EqualValues(t, http.StatusBadRequest, res.StatusCode) } require.Contains(t, gjson.Get(body, "ui.messages").String(), "The login code is invalid or has already been used. Please try again") }) - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode2) v.Set("identifier", s.identityEmail) }, true, nil) }) t.Run("case=on login with un-verified address, should verify it", func(t *testing.T) { - s := createLoginFlow(ctx, t, public, tc.isSPA, testhelpers.RandomEmail()) + s := createLoginFlow(ctx, t, public, tc.apiType, testhelpers.RandomEmail()) // we need to fetch only the first email loginEmail := gjson.Get(s.identity.Traits.String(), "email_1").String() @@ -420,7 +455,7 @@ func TestLoginCodeStrategy(t *testing.T) { require.False(t, va.Verified) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -431,7 +466,7 @@ func TestLoginCodeStrategy(t *testing.T) { require.NotEmpty(t, loginCode) // Submit OTP - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) v.Set("identifier", s.identityEmail) }, true, nil) @@ -453,6 +488,10 @@ func TestLoginCodeStrategy(t *testing.T) { }) t.Run("case=should not populate on 2FA request", func(t *testing.T) { + if tc.apiType == ApiTypeNative { + t.Skip("skipping test since it is not applicable to native clients") + } + ctx := context.Background() // enable webauthn 2FA method @@ -464,10 +503,10 @@ func TestLoginCodeStrategy(t *testing.T) { conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1") }) - s := createLoginFlow(ctx, t, public, tc.isSPA) + s := createLoginFlow(ctx, t, public, tc.apiType) // submit email - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("identifier", s.identityEmail) }, false, nil) @@ -478,17 +517,17 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode) // 3. Submit OTP - s = submitLogin(ctx, t, s, tc.isSPA, func(v *url.Values) { + s = submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) }, false, func(t *testing.T, s *state, body string, res *http.Response) { - if tc.isSPA { + if tc.apiType == ApiTypeSPA { require.EqualValues(t, http.StatusOK, res.StatusCode) } else { require.EqualValues(t, http.StatusOK, res.StatusCode) } }) - clientInit := testhelpers.InitializeLoginFlowViaBrowser(t, s.client, public, false, tc.isSPA, false, false, testhelpers.InitFlowWithAAL("aal2")) + clientInit := testhelpers.InitializeLoginFlowViaBrowser(t, s.client, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2")) body, err := json.Marshal(clientInit) require.NoError(t, err) require.Len(t, gjson.GetBytes(body, "ui.nodes.#(group==code)").Array(), 0, "should not populate code field on 2fa request") diff --git a/selfservice/strategy/code/strategy_registration_test.go b/selfservice/strategy/code/strategy_registration_test.go index 2e4fe674fff0..e591e627f5ef 100644 --- a/selfservice/strategy/code/strategy_registration_test.go +++ b/selfservice/strategy/code/strategy_registration_test.go @@ -26,6 +26,7 @@ import ( "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" + oryClient "github.com/ory/kratos/internal/httpclient" "github.com/ory/kratos/internal/testhelpers" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/strategy/code" @@ -83,6 +84,14 @@ func TestRegistrationCodeStrategyDisabled(t *testing.T) { } func TestRegistrationCodeStrategy(t *testing.T) { + type ApiType string + + const ( + ApiTypeBrowser ApiType = "browser" + ApiTypeSPA ApiType = "spa" + ApiTypeNative ApiType = "api" + ) + setup := func(ctx context.Context, t *testing.T) (*config.Config, *driver.RegistryDefault, *httptest.Server) { conf, reg := internal.NewFastRegistryWithMocks(t) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") @@ -103,18 +112,35 @@ func TestRegistrationCodeStrategy(t *testing.T) { return conf, reg, public } - createRegistrationFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, isSPA bool) *state { + createRegistrationFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType) *state { t.Helper() - client := testhelpers.NewClientWithCookies(t) + var client *http.Client + + if apiType == ApiTypeNative { + client = &http.Client{} + } else { + client = testhelpers.NewClientWithCookies(t) + } + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper - clientInit := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, public, isSPA, false, false) + + var clientInit *oryClient.RegistrationFlow + if apiType == ApiTypeNative { + clientInit = testhelpers.InitializeRegistrationFlowViaAPI(t, client, public) + } else { + clientInit = testhelpers.InitializeRegistrationFlowViaBrowser(t, client, public, apiType == ApiTypeSPA, false, false) + } body, err := json.Marshal(clientInit) require.NoError(t, err) csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmpty(t, csrfToken) + if apiType == ApiTypeNative { + require.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body) + } else { + require.NotEmpty(t, csrfToken) + } require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.email)").Exists(), "%s", body) require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body) @@ -128,7 +154,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { type onSubmitAssertion func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) - registerNewUser := func(ctx context.Context, t *testing.T, s *state, isSPA bool, submitAssertion onSubmitAssertion) *state { + registerNewUser := func(ctx context.Context, t *testing.T, s *state, apiType ApiType, submitAssertion onSubmitAssertion) *state { t.Helper() if s.email == "" { @@ -144,26 +170,31 @@ func TestRegistrationCodeStrategy(t *testing.T) { values.Set("traits.tos", "1") values.Set("method", "code") - body, resp := testhelpers.RegistrationMakeRequest(t, false, isSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, false, values)) + body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) if submitAssertion != nil { submitAssertion(ctx, t, s, body, resp) return s } - if isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - } else { + if apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } + csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - assert.NotEmptyf(t, csrfToken, "%s", body) + if apiType == ApiTypeNative { + assert.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body) + } else { + assert.NotEmptyf(t, csrfToken, "%s", body) + } require.Equal(t, s.email, gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).attributes.value").String()) return s } - submitOTP := func(ctx context.Context, t *testing.T, reg *driver.RegistryDefault, s *state, vals func(v *url.Values), isSPA bool, submitAssertion onSubmitAssertion) *state { + submitOTP := func(ctx context.Context, t *testing.T, reg *driver.RegistryDefault, s *state, vals func(v *url.Values), apiType ApiType, submitAssertion onSubmitAssertion) *state { t.Helper() rf, resp, err := testhelpers.NewSDKCustomClient(s.testServer, s.client).FrontendApi.GetRegistrationFlow(context.Background()).Id(s.flowID).Execute() @@ -179,7 +210,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { values.Set("traits.tos", "1") vals(&values) - body, resp := testhelpers.RegistrationMakeRequest(t, false, isSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, false, values)) + body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) if submitAssertion != nil { submitAssertion(ctx, t, s, body, resp) @@ -210,16 +241,20 @@ func TestRegistrationCodeStrategy(t *testing.T) { _, reg, public := setup(ctx, t) for _, tc := range []struct { - d string - isSPA bool + d string + apiType ApiType }{ { - d: "SPA client", - isSPA: true, + d: "SPA client", + apiType: ApiTypeSPA, + }, + { + d: "Browser client", + apiType: ApiTypeBrowser, }, { - d: "Browser client", - isSPA: false, + d: "Native client", + apiType: ApiTypeNative, }, } { t.Run("flow="+tc.d, func(t *testing.T) { @@ -227,10 +262,10 @@ func TestRegistrationCodeStrategy(t *testing.T) { ctx := context.Background() // 1. Initiate flow - state := createRegistrationFlow(ctx, t, public, tc.isSPA) + state := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - state = registerNewUser(ctx, t, state, tc.isSPA, nil) + state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Complete your account registration") assert.Contains(t, message.Body, "please complete your account registration by entering the following code") @@ -241,20 +276,20 @@ func TestRegistrationCodeStrategy(t *testing.T) { // 3. Submit OTP state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) - }, tc.isSPA, nil) + }, tc.apiType, nil) }) t.Run("case=should normalize email address on sign up", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow - state := createRegistrationFlow(ctx, t, public, tc.isSPA) + state := createRegistrationFlow(ctx, t, public, tc.apiType) sourceMail := testhelpers.RandomEmail() state.email = strings.ToUpper(sourceMail) assert.NotEqual(t, sourceMail, state.email) // 2. Submit Identifier (email) - state = registerNewUser(ctx, t, state, tc.isSPA, nil) + state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, sourceMail, "Complete your account registration") assert.Contains(t, message.Body, "please complete your account registration by entering the following code") @@ -265,7 +300,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { // 3. Submit OTP state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) - }, tc.isSPA, nil) + }, tc.apiType, nil) creds, ok := state.resultIdentity.GetCredentials(identity.CredentialsTypeCodeAuth) require.True(t, ok) @@ -276,16 +311,21 @@ func TestRegistrationCodeStrategy(t *testing.T) { t.Run("case=should be able to resend the code", func(t *testing.T) { ctx := context.Background() - s := createRegistrationFlow(ctx, t, public, tc.isSPA) + s := createRegistrationFlow(ctx, t, public, tc.apiType) - s = registerNewUser(ctx, t, s, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - } else { + s = registerNewUser(ctx, t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } + csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmptyf(t, csrfToken, "%s", body) + if tc.apiType == ApiTypeNative { + require.Empty(t, csrfToken, "expected the csrf_token to be empty but got %s", body) + } else { + require.NotEmptyf(t, csrfToken, "expected the csrf_token to exist but got %s", body) + } require.Equal(t, s.email, gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).attributes.value").String()) attr := gjson.Get(body, "ui.nodes.#(attributes.name==method)#").String() @@ -304,14 +344,18 @@ func TestRegistrationCodeStrategy(t *testing.T) { // resend code s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("resend", "code") - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode) + } else { + require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmptyf(t, csrfToken, "%s", body) + if tc.apiType == ApiTypeNative { + assert.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body) + } else { + require.NotEmptyf(t, csrfToken, "expected to find the csrf_token but got %s", body) + } require.Containsf(t, gjson.Get(body, "ui.messages").String(), "An email containing a code has been sent to the email address you provided.", "%s", body) }) @@ -327,28 +371,28 @@ func TestRegistrationCodeStrategy(t *testing.T) { // try submit old code s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages").String(), "The registration code is invalid or has already been used. Please try again") }) s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode2) - }, tc.isSPA, nil) + }, tc.apiType, nil) }) t.Run("case=swapping out traits should not be possible on code submit", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow - s := createRegistrationFlow(ctx, t, public, tc.isSPA) + s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - s = registerNewUser(ctx, t, s, tc.isSPA, nil) + s = registerNewUser(ctx, t, s, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Complete your account registration") assert.Contains(t, message.Body, "please complete your account registration by entering the following code") @@ -360,11 +404,11 @@ func TestRegistrationCodeStrategy(t *testing.T) { // 3. Submit OTP s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The provided traits do not match the traits previously associated with this flow.") }) @@ -374,10 +418,10 @@ func TestRegistrationCodeStrategy(t *testing.T) { ctx := context.Background() // 1. Initiate flow - s := createRegistrationFlow(ctx, t, public, tc.isSPA) + s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - s = registerNewUser(ctx, t, s, tc.isSPA, nil) + s = registerNewUser(ctx, t, s, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Complete your account registration") assert.Contains(t, message.Body, "please complete your account registration by entering the following code") @@ -389,11 +433,11 @@ func TestRegistrationCodeStrategy(t *testing.T) { s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) v.Set("traits.tos", "0") - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The provided traits do not match the traits previously associated with this flow.") }) @@ -403,10 +447,10 @@ func TestRegistrationCodeStrategy(t *testing.T) { ctx := context.Background() // 1. Initiate flow - s := createRegistrationFlow(ctx, t, public, tc.isSPA) + s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - s = registerNewUser(ctx, t, s, tc.isSPA, nil) + s = registerNewUser(ctx, t, s, tc.apiType, nil) reg.Persister().Transaction(ctx, func(ctx context.Context, connection *pop.Connection) error { count, err := connection.RawQuery(fmt.Sprintf("SELECT * FROM %s WHERE selfservice_registration_flow_id = ?", new(code.RegistrationCode).TableName(ctx)), uuid.FromStringOrNil(s.flowID)).Count(new(code.RegistrationCode)) @@ -418,11 +462,11 @@ func TestRegistrationCodeStrategy(t *testing.T) { for i := 0; i < 5; i++ { s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", "111111") - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The registration code is invalid or has already been used") }) @@ -430,11 +474,11 @@ func TestRegistrationCodeStrategy(t *testing.T) { s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", "111111") - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The request was submitted too often.") }) @@ -448,16 +492,20 @@ func TestRegistrationCodeStrategy(t *testing.T) { conf, reg, public := setup(ctx, t) for _, tc := range []struct { - d string - isSPA bool + d string + apiType ApiType }{ { - d: "SPA client", - isSPA: true, + d: "SPA client", + apiType: ApiTypeSPA, + }, + { + d: "Browser client", + apiType: ApiTypeBrowser, }, { - d: "Browser client", - isSPA: false, + d: "Native client", + apiType: ApiTypeNative, }, } { t.Run("test="+tc.d, func(t *testing.T) { @@ -468,14 +516,11 @@ func TestRegistrationCodeStrategy(t *testing.T) { }) // 1. Initiate flow - s := createRegistrationFlow(ctx, t, public, tc.isSPA) + s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - s = registerNewUser(ctx, t, s, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusBadRequest, resp.StatusCode) - require.Contains(t, gjson.Get(body, "ui.messages").String(), "Could not find any login identifiers") - } else { + s = registerNewUser(ctx, t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { // we expect a redirect to the registration page with the flow id require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, conf.SelfServiceFlowRegistrationUI(ctx).Path, resp.Request.URL.Path) @@ -486,6 +531,9 @@ func TestRegistrationCodeStrategy(t *testing.T) { require.NoError(t, err) require.Contains(t, gjson.GetBytes(body, "ui.messages").String(), "Could not find any login identifiers") + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Contains(t, gjson.Get(body, "ui.messages").String(), "Could not find any login identifiers") } }) }) @@ -501,10 +549,10 @@ func TestRegistrationCodeStrategy(t *testing.T) { }) // 1. Initiate flow - state := createRegistrationFlow(ctx, t, public, tc.isSPA) + state := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - state = registerNewUser(ctx, t, state, tc.isSPA, nil) + state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Complete your account registration") assert.Contains(t, message.Body, "please complete your account registration by entering the following code") @@ -515,7 +563,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { // 3. Submit OTP state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) - }, tc.isSPA, nil) + }, tc.apiType, nil) }) t.Run("case=code should expire", func(t *testing.T) { @@ -525,10 +573,10 @@ func TestRegistrationCodeStrategy(t *testing.T) { }) // 1. Initiate flow - s := createRegistrationFlow(ctx, t, public, tc.isSPA) + s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) - s = registerNewUser(ctx, t, s, tc.isSPA, nil) + s = registerNewUser(ctx, t, s, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Complete your account registration") assert.Contains(t, message.Body, "please complete your account registration by entering the following code") @@ -538,15 +586,15 @@ func TestRegistrationCodeStrategy(t *testing.T) { s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) - }, tc.isSPA, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { - if tc.isSPA { - require.Equal(t, http.StatusGone, resp.StatusCode) - require.Containsf(t, gjson.Get(body, "error.reason").String(), "self-service flow expired 0.00 minutes ago", "%s", body) - } else { + }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { + if tc.apiType == ApiTypeBrowser { // with browser clients we redirect back to the UI with a new flow id as a query parameter require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, conf.SelfServiceFlowRegistrationUI(ctx).Path, resp.Request.URL.Path) require.NotEqual(t, s.flowID, resp.Request.URL.Query().Get("flow")) + } else { + require.Equal(t, http.StatusGone, resp.StatusCode) + require.Containsf(t, gjson.Get(body, "error.reason").String(), "self-service flow expired 0.00 minutes ago", "%s", body) } }) }) diff --git a/test/e2e/cypress/integration/profiles/code/login/error.spec.ts b/test/e2e/cypress/integration/profiles/code/login/error.spec.ts index 899eafb0514a..3dbc3729909d 100644 --- a/test/e2e/cypress/integration/profiles/code/login/error.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/login/error.spec.ts @@ -1,7 +1,7 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { appPrefix, APP_URL, gen } from "../../../../helpers" +import { appPrefix, APP_URL, gen, MOBILE_URL } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" @@ -17,12 +17,31 @@ context("Login error messages with code method", () => { app: "react" as "react", profile: "code", }, + { + route: MOBILE_URL + "/Login", + app: "mobile" as "mobile", + profile: "code", + }, ].forEach(({ route, profile, app }) => { describe(`for app ${app}`, () => { + const Selectors = { + mobile: { + identity: '[data-testid="identifier"]', + code: '[data-testid="code"]', + }, + express: { + identity: 'input[name="identifier"]', + code: 'input[name="code"]', + }, + } + Selectors["react"] = Selectors["express"] + before(() => { cy.useConfigProfile(profile) cy.deleteMail() - cy.proxy(app) + if (app !== "mobile") { + cy.proxy(app) + } }) beforeEach(() => { @@ -41,10 +60,8 @@ context("Login error messages with code method", () => { it("should show error message when account identifier does not exist", () => { const email = gen.email() - cy.get('input[name="identifier"]').type(email) - cy.submitCodeForm() - - cy.url().should("contain", "login") + cy.get(Selectors[app]["identity"]).type(email) + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/4000035"]').should( "contain", @@ -54,19 +71,18 @@ context("Login error messages with code method", () => { it("should show error message when code is invalid", () => { cy.get("@email").then((email) => { - cy.get('input[name="identifier"]').clear().type(email.toString()) + cy.get(Selectors[app]["identity"]).clear().type(email.toString()) }) - cy.submitCodeForm() + cy.submitCodeForm(app) - cy.url().should("contain", "login") cy.get('[data-testid="ui/message/1010014"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) - cy.get('input[name="code"]').type("invalid-code") - cy.submitCodeForm() + cy.get(Selectors[app]["code"]).type("invalid-code") + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/4010008"]').should( "contain", @@ -76,16 +92,31 @@ context("Login error messages with code method", () => { it("should show error message when identifier has changed", () => { cy.get("@email").then((email) => { - cy.get('input[name="identifier"]').type(email.toString()) + cy.get(Selectors[app]["identity"]).type(email.toString()) }) - cy.submitCodeForm() + cy.submitCodeForm(app) + + if (app !== "express") { + cy.intercept("POST", "/self-service/login*", (req) => { + req.body = { + ...req.body, + identifier: gen.email(), + } + req.continue() + }).as("login") + } else { + cy.get(Selectors[app]["identity"]) + .type("{selectall}{backspace}", { force: true }) + .type(gen.email(), { force: true }) + } - cy.url().should("contain", "login") - cy.get('input[name="identifier"]').clear().type(gen.email()) - cy.get('input[name="code"]').type("invalid-code") - cy.submitCodeForm() + cy.get(Selectors[app]["code"]).type("invalid-code") + cy.submitCodeForm(app) + if (app !== "express") { + cy.wait("@login") + } cy.get('[data-testid="ui/message/4000035"]').should( "contain", "This account does not exist or has not setup sign in with code.", @@ -94,30 +125,45 @@ context("Login error messages with code method", () => { it("should show error message when required fields are missing", () => { cy.get("@email").then((email) => { - cy.get('input[name="identifier"]').type(email.toString()) + cy.get(Selectors[app]["identity"]).type(email.toString()) }) - cy.submitCodeForm() - cy.url().should("contain", "login") + cy.submitCodeForm(app) - cy.removeAttribute(['input[name="code"]'], "required") - cy.submitCodeForm() + cy.removeAttribute([Selectors[app]["code"]], "required") + cy.submitCodeForm(app) - cy.get('[data-testid="ui/message/4000002"]').should( - "contain", - "Property code is missing", - ) + if (app === "mobile") { + cy.get('[data-testid="field/code"]').should( + "contain", + "Property code is missing", + ) + } else { + cy.get('[data-testid="ui/message/4000002"]').should( + "contain", + "Property code is missing", + ) + } - cy.get('input[name="code"]').type("invalid-code") - cy.removeAttribute(['input[name="identifier"]'], "required") + cy.get(Selectors[app]["code"]).type("invalid-code") + cy.removeAttribute([Selectors[app]["identity"]], "required") - cy.get('input[name="identifier"]').clear() + cy.get(Selectors[app]["identity"]).type("{selectall}{backspace}", { + force: true, + }) - cy.submitCodeForm() - cy.get('[data-testid="ui/message/4000002"]').should( - "contain", - "Property identifier is missing", - ) + cy.submitCodeForm(app) + if (app === "mobile") { + cy.get('[data-testid="field/identifier"]').should( + "contain", + "Property identifier is missing", + ) + } else { + cy.get('[data-testid="ui/message/4000002"]').should( + "contain", + "Property identifier is missing", + ) + } }) it("should show error message when code is expired", () => { @@ -134,19 +180,17 @@ context("Login error messages with code method", () => { }) cy.get("@email").then((email) => { - cy.get('input[name="identifier"]').type(email.toString()) + cy.get(Selectors[app]["identity"]).type(email.toString()) }) - cy.submitCodeForm() - - cy.url().should("contain", "login") + cy.submitCodeForm(app) cy.get("@email").then((email) => { cy.getLoginCodeFromEmail(email.toString()).should((code) => { - cy.get('input[name="code"]').type(code) + cy.get(Selectors[app]["code"]).type(code) }) }) - cy.submitCodeForm() + cy.submitCodeForm(app) // the react app does not show the error message for 410 errors // it just creates a new flow @@ -156,7 +200,7 @@ context("Login error messages with code method", () => { "The login flow expired", ) } else { - cy.get("input[name=identifier]").should("be.visible") + cy.get(Selectors[app]["identity"]).should("be.visible") } cy.noSession() diff --git a/test/e2e/cypress/integration/profiles/code/login/success.spec.ts b/test/e2e/cypress/integration/profiles/code/login/success.spec.ts index 2f882717edbd..1f6635c25aa9 100644 --- a/test/e2e/cypress/integration/profiles/code/login/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/login/success.spec.ts @@ -1,7 +1,8 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { gen } from "../../../../helpers" +import { Session } from "@ory/kratos-client" +import { gen, MOBILE_URL } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" @@ -17,12 +18,35 @@ context("Login success with code method", () => { app: "react" as "react", profile: "code", }, + { + route: MOBILE_URL + "/Login", + app: "mobile" as "mobile", + profile: "code", + }, ].forEach(({ route, profile, app }) => { describe(`for app ${app}`, () => { + const Selectors = { + mobile: { + identity: '[data-testid="identifier"]', + code: '[data-testid="code"]', + submit: '[data-testid="field/method/code"]', + resend: '[data-testid="field/resend/code"]', + }, + express: { + identity: 'input[name="identifier"]', + code: 'input[name="code"]', + submit: 'button[name="method"][value="code"]', + resend: 'button[name="resend"]', + }, + } + Selectors["react"] = Selectors["express"] + before(() => { cy.deleteMail() cy.useConfigProfile(profile) - cy.proxy(app) + if (app !== "mobile") { + cy.proxy(app) + } cy.setPostCodeRegistrationHooks([]) cy.setupHooks("login", "after", "code", []) }) @@ -39,19 +63,39 @@ context("Login success with code method", () => { it("should be able to sign in with code", () => { cy.get("@email").then((email) => { - cy.get('input[name="identifier"]').clear().type(email.toString()) - cy.submitCodeForm() + cy.get(Selectors[app]["identity"]).clear().type(email.toString()) + cy.submitCodeForm(app) cy.getLoginCodeFromEmail(email.toString()).should((code) => { - cy.get('input[name="code"]').type(code) + cy.get(Selectors[app]["code"]).type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["submit"]).click() }) cy.location("pathname").should("not.contain", "login") - cy.getSession().should((session) => { - const { identity } = session + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then(({ identity }) => { expect(identity.id).to.not.be.empty expect(identity.verifiable_addresses).to.have.length(1) expect(identity.verifiable_addresses[0].status).to.equal( @@ -64,14 +108,14 @@ context("Login success with code method", () => { it("should be able to resend login code", () => { cy.get("@email").then((email) => { - cy.get('input[name="identifier"]').clear().type(email.toString()) - cy.submitCodeForm() + cy.get(Selectors[app]["identity"]).clear().type(email.toString()) + cy.submitCodeForm(app) cy.getLoginCodeFromEmail(email.toString()).should((code) => { cy.wrap(code).as("code1") }) - cy.get("button[name=resend]").click() + cy.get(Selectors[app]["resend"]).click() cy.getLoginCodeFromEmail(email.toString()).should((code) => { cy.wrap(code).as("code2") @@ -85,10 +129,10 @@ context("Login success with code method", () => { // attempt to submit code 1 cy.get("@code1").then((code1) => { - cy.get('input[name="code"]').clear().type(code1.toString()) + cy.get(Selectors[app]["code"]).clear().type(code1.toString()) }) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["submit"]).click() cy.get("[data-testid='ui/message/4010008']").contains( "The login code is invalid or has already been used", @@ -96,15 +140,37 @@ context("Login success with code method", () => { // attempt to submit code 2 cy.get("@code2").then((code2) => { - cy.get('input[name="code"]').clear().type(code2.toString()) + cy.get(Selectors[app]["code"]).clear().type(code2.toString()) }) - cy.get('button[name="method"][value="code"]').click() + cy.get(Selectors[app]["submit"]).click() if (app === "express") { cy.get('a[href*="sessions"').click() } - cy.getSession().should((session) => { + + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then((session) => { const { identity } = session expect(identity.id).to.not.be.empty expect(identity.verifiable_addresses).to.have.length(1) @@ -139,25 +205,45 @@ context("Login success with code method", () => { cy.visit(route) - cy.get('input[name="identifier"]').clear().type(email2) - cy.submitCodeForm() + cy.get(Selectors[app]["identity"]).clear().type(email2) + cy.submitCodeForm(app) cy.getLoginCodeFromEmail(email2).should((code) => { - cy.get('input[name="code"]').type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["code"]).type(code) + cy.get(Selectors[app]["submit"]).click() }) - cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( - (session) => { - expect(session.identity.verifiable_addresses).to.have.length(2) - expect(session.identity.verifiable_addresses[0].status).to.equal( - "completed", - ) - expect(session.identity.verifiable_addresses[1].status).to.equal( - "completed", - ) - }, - ) + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + cy.get('[data-testid="session-content"]').should("contain", email2) + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then((session) => { + expect(session?.identity?.verifiable_addresses).to.have.length(2) + expect(session?.identity?.verifiable_addresses[0].status).to.equal( + "completed", + ) + expect(session.identity.verifiable_addresses[1].status).to.equal( + "completed", + ) + }) }) }) }) diff --git a/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts b/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts index b96e7b726380..48c8005d28e0 100644 --- a/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts @@ -1,6 +1,7 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { gen } from "../../../../helpers" +import { UiNode } from "@ory/kratos-client" +import { gen, MOBILE_URL } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" @@ -16,10 +17,33 @@ context("Registration error messages with code method", () => { app: "react" as "react", profile: "code", }, + { + route: MOBILE_URL + "/Registration", + app: "mobile" as "mobile", + profile: "code", + }, ].forEach(({ route, profile, app }) => { describe(`for app ${app}`, () => { + const Selectors = { + mobile: { + identifier: "[data-testid='field/identifier']", + email: "[data-testid='field/traits.email']", + tos: "[data-testid='traits.tos']", + code: "[data-testid='field/code']", + }, + express: { + identifier: "input[name='identifier']", + email: "input[name='traits.email']", + tos: "[name='traits.tos'] + label", + code: "input[name='code']", + }, + } + Selectors["react"] = Selectors["express"] + before(() => { - cy.proxy(app) + if (app !== "mobile") { + cy.proxy(app) + } cy.useConfigProfile(profile) cy.deleteMail() }) @@ -33,18 +57,18 @@ context("Registration error messages with code method", () => { it("should show error message when code is invalid", () => { const email = gen.email() - cy.get('input[name="traits.email"]').type(email) - cy.get('[name="traits.tos"] + label').click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(Selectors[app]["tos"]).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) - cy.get('input[name="code"]').type("invalid-code") - cy.submitCodeForm() + cy.get(Selectors[app]["code"]).type("invalid-code") + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/4040003"]').should( "contain", @@ -55,20 +79,37 @@ context("Registration error messages with code method", () => { it("should show error message when traits have changed", () => { const email = gen.email() - cy.get('input[name="traits.email"]').type(email) - cy.get('[name="traits.tos"] + label').click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(Selectors[app]["tos"]).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) - cy.get('input[name="traits.email"]') - .clear() - .type("changed-email@email.com") - cy.get('input[name="code"]').type("invalid-code") - cy.submitCodeForm() + if (app !== "express") { + // the mobile app doesn't render hidden fields in the DOM + // we need to replace the request body + cy.intercept("POST", "/self-service/registration*", (req) => { + req.body = { + ...req.body, + "traits.email": "changed-email@email.com", + } + req.continue() + }).as("registration") + } else { + cy.get(Selectors[app]["email"]) + .type("{selectall}{backspace}", { force: true }) + .type("changed-email@email.com", { force: true }) + } + + cy.get(Selectors[app]["code"]).type("invalid-code") + cy.submitCodeForm(app) + + if (app !== "express") { + cy.wait("@registration") + } cy.get('[data-testid="ui/message/4000036"]').should( "contain", @@ -79,32 +120,65 @@ context("Registration error messages with code method", () => { it("should show error message when required fields are missing", () => { const email = gen.email() - cy.get('input[name="traits.email"]').type(email) - cy.get('[name="traits.tos"] + label').click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(Selectors[app]["tos"]).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) - cy.removeAttribute(['input[name="code"]'], "required") + cy.removeAttribute([Selectors[app]["code"]], "required") - cy.submitCodeForm() - cy.get('[data-testid="ui/message/4000002"]').should( - "contain", - "Property code is missing", - ) + cy.submitCodeForm(app) + + if (app === "mobile") { + cy.get('[data-testid="field/code"]').should( + "contain", + "Property code is missing", + ) + } else { + cy.get('[data-testid="ui/message/4000002"]').should( + "contain", + "Property code is missing", + ) + } + + if (app !== "express") { + // the mobile app doesn't render hidden fields in the DOM + // we need to replace the request body + cy.intercept("POST", "/self-service/registration*", (req) => { + delete req.body["traits.email"] + req.continue((res) => { + const emailInput = res.body.ui.nodes.find( + (n: UiNode) => + "name" in n.attributes && + n.attributes.name === "traits.email", + ) + expect(emailInput).to.not.be.undefined + expect(emailInput.messages).to.not.be.undefined + expect(emailInput.messages[0].text).to.contain("email is missing") + }) + }).as("registration") + } else { + cy.get(Selectors[app]["email"]).type("{selectall}{backspace}", { + force: true, + }) + cy.removeAttribute([Selectors[app]["email"]], "required") + } + cy.get(Selectors[app]["code"]).type("invalid-code") - cy.get('input[name="traits.email"]').clear() - cy.get('input[name="code"]').type("invalid-code") - cy.removeAttribute(['input[name="traits.email"]'], "required") + cy.submitCodeForm(app) - cy.submitCodeForm() - cy.get('[data-testid="ui/message/4000002"]').should( - "contain", - "Property email is missing", - ) + if (app !== "express") { + cy.wait("@registration") + } else { + cy.get('[data-testid="ui/message/4000002"]').should( + "contain", + "Property email is missing", + ) + } }) it("should show error message when code is expired", () => { @@ -115,18 +189,18 @@ context("Registration error messages with code method", () => { cy.visit(route) const email = gen.email() - cy.get('input[name="traits.email"]').type(email) - cy.get('[name="traits.tos"] + label').click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(Selectors[app]["tos"]).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) cy.getRegistrationCodeFromEmail(email).should((code) => { - cy.get('input[name="code"]').type(code) - cy.submitCodeForm() + cy.get(Selectors[app]["code"]).type(code) + cy.submitCodeForm(app) }) // in the react spa app we don't show the 410 gone error. we create a new flow. @@ -136,7 +210,7 @@ context("Registration error messages with code method", () => { "The registration flow expired", ) } else { - cy.get('input[name="traits.email"]').should("be.visible") + cy.get(Selectors[app]["email"]).should("be.visible") } cy.noSession() diff --git a/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts b/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts index 4ce2d0703b1d..a07cd6d51b26 100644 --- a/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts @@ -1,7 +1,8 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { gen } from "../../../../helpers" +import { Session } from "@ory/kratos-client" +import { gen, MOBILE_URL } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" @@ -21,12 +22,53 @@ context("Registration success with code method", () => { app: "react" as "react", profile: "code", }, + { + route: MOBILE_URL + "/Registration", + login: MOBILE_URL + "/Login", + recovery: MOBILE_URL + "/Recovery", + app: "mobile" as "mobile", + profile: "code", + }, ].forEach(({ route, login, recovery, profile, app }) => { describe(`for app ${app}`, () => { + const Selectors = { + mobile: { + identifier: "[data-testid='field/identifier']", + recoveryEmail: "[data-testid='field/email']", + email: "[data-testid='traits.email']", + email2: "[data-testid='traits.email2']", + tos: "[data-testid='traits.tos']", + username: "[data-testid='traits.username']", + code: "[data-testid='field/code']", + recoveryCode: "[data-testid='code']", + submitCode: "[data-testid='field/method/code']", + resendCode: "[data-testid='field/method/resend']", + submitRecovery: "[data-testid='field/method/code']", + codeHiddenMethod: "[data-testid='field/method/code']", + }, + express: { + identifier: "input[name='identifier']", + recoveryEmail: "input[name=email]", + email: "input[name='traits.email']", + email2: "input[name='traits.email2']", + tos: "[name='traits.tos'] + label", + username: "input[name='traits.username']", + code: "input[name='code']", + recoveryCode: "input[name=code]", + submitRecovery: "button[name=method][value=code]", + submitCode: "button[name='method'][value='code']", + resendCode: "button[name='resend'][value='code']", + codeHiddenMethod: "input[name='method'][value='code'][type='hidden']", + }, + } + Selectors["react"] = Selectors["express"] + before(() => { cy.deleteMail() cy.useConfigProfile(profile) - cy.proxy(app) + if (app !== "mobile") { + cy.proxy(app) + } }) beforeEach(() => { @@ -38,10 +80,10 @@ context("Registration success with code method", () => { it("should be able to resend the registration code", async () => { const email = gen.email() - cy.get(`input[name='traits.email']`).type(email) - cy.get(`[name='traits.tos'] + label`).click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(`${Selectors[app]["tos"]} + label`).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", @@ -51,11 +93,9 @@ context("Registration success with code method", () => { cy.wrap(code).as("code1"), ) - cy.get(`input[name='traits.email']`).should("have.value", email) - cy.get(`input[name='method'][value='code'][type='hidden']`).should( - "exist", - ) - cy.get(`button[name='resend'][value='code']`).click() + cy.get(Selectors[app]["email"]).should("have.value", email) + cy.get(Selectors[app]["codeHiddenMethod"]).should("exist") + cy.get(Selectors[app]["resendCode"]).click() cy.getRegistrationCodeFromEmail(email).should((code) => { cy.wrap(code).as("code2") @@ -63,9 +103,9 @@ context("Registration success with code method", () => { cy.get("@code1").then((code1) => { // previous code should not work - cy.get('input[name="code"]').clear().type(code1.toString()) + cy.get(Selectors[app]["code"]).clear().type(code1.toString()) - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/4040003"]').should( "contain.text", "The registration code is invalid or has already been used. Please try again.", @@ -73,12 +113,32 @@ context("Registration success with code method", () => { }) cy.get("@code2").then((code2) => { - cy.get('input[name="code"]').clear().type(code2.toString()) - cy.submitCodeForm() + cy.get(Selectors[app]["code"]).clear().type(code2.toString()) + cy.submitCodeForm(app) }) - cy.getSession().should((session) => { - const { identity } = session + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then(({ identity }) => { expect(identity.id).to.not.be.empty expect(identity.verifiable_addresses).to.have.length(1) expect(identity.verifiable_addresses[0].status).to.equal("completed") @@ -89,22 +149,42 @@ context("Registration success with code method", () => { it("should sign up and be logged in with session hook", () => { const email = gen.email() - cy.get(`input[name='traits.email']`).type(email) - cy.get(`[name='traits.tos'] + label`).click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(Selectors[app]["tos"]).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) cy.getRegistrationCodeFromEmail(email).should((code) => { - cy.get(`input[name=code]`).type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["code"]).type(code) + cy.get(Selectors[app]["submitCode"]).click() }) - cy.getSession().should((session) => { - const { identity } = session + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then(({ identity }) => { expect(identity.id).to.not.be.empty expect(identity.verifiable_addresses).to.have.length(1) expect(identity.verifiable_addresses[0].status).to.equal("completed") @@ -116,31 +196,51 @@ context("Registration success with code method", () => { cy.setPostCodeRegistrationHooks([]) const email = gen.email() - cy.get(`input[name='traits.email']`).type(email) - cy.get(`[name='traits.tos'] + label`).click() + cy.get(Selectors[app]["email"]).type(email) + cy.get(Selectors[app]["tos"]).click() - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", ) cy.getRegistrationCodeFromEmail(email).should((code) => { - cy.get(`input[name=code]`).type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["code"]).type(code) + cy.get(Selectors[app]["submitCode"]).click() }) cy.visit(login) - cy.get(`input[name=identifier]`).type(email) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["identifier"]).type(email) + cy.get(Selectors[app]["submitCode"]).click() cy.getLoginCodeFromEmail(email).then((code) => { - cy.get(`input[name=code]`).type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["code"]).type(code) + cy.get(Selectors[app]["submitCode"]).click() }) - cy.getSession().should((session) => { - const { identity } = session + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then(({ identity }) => { expect(identity.id).to.not.be.empty expect(identity.verifiable_addresses).to.have.length(1) expect(identity.verifiable_addresses[0].status).to.equal("completed") @@ -149,20 +249,48 @@ context("Registration success with code method", () => { }) it("should be able to recover account when registered with code", () => { + if (app === "mobile") { + cy.log("WARNING: skipping test for mobile app") + return + } const email = gen.email() cy.registerWithCode({ email, traits: { "traits.tos": 1 } }) cy.clearAllCookies() cy.visit(recovery) - cy.get('input[name="email"]').type(email) - cy.get('button[name="method"][value="code"]').click() + cy.get(Selectors[app]["recoveryEmail"]).type(email) + cy.get(Selectors[app]["submitRecovery"]).click() - cy.recoveryEmailWithCode({ expect: { email } }) - cy.get('button[value="code"]').click() + cy.recoveryEmailWithCode({ expect: { email, enterCode: false } }).then( + () => { + cy.get("@recoveryCode").then((code) => { + cy.get(Selectors[app]["recoveryCode"]).type(code) + }) + }, + ) - cy.getSession().should((session) => { - const { identity } = session + cy.get(Selectors[app]["submitRecovery"]).click() + + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1" }).then((session) => { + cy.wrap(session).as("session") + }) + } + + cy.get("@session").then(({ identity }) => { expect(identity.id).to.not.be.empty expect(identity.traits.email).to.equal(email) }) @@ -183,15 +311,15 @@ context("Registration success with code method", () => { cy.visit(route) - cy.get(`input[name='traits.username']`).type(Math.random().toString(36)) + cy.get(Selectors[app]["username"]).type(Math.random().toString(36)) const email = gen.email() - cy.get(`input[name='traits.email']`).type(email) + cy.get(Selectors[app]["email"]).type(email) const email2 = gen.email() - cy.get(`input[name='traits.email2']`).type(email2) + cy.get(Selectors[app]["email2"]).type(email2) - cy.submitCodeForm() + cy.submitCodeForm(app) cy.get('[data-testid="ui/message/1040005"]').should( "contain", "An email containing a code has been sent to the email address you provided", @@ -200,12 +328,17 @@ context("Registration success with code method", () => { // intentionally use email 1 to sign up for the account cy.getRegistrationCodeFromEmail(email, { expectedCount: 1 }).should( (code) => { - cy.get(`input[name=code]`).type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["code"]).type(code) + cy.get(Selectors[app]["submitCode"]).click() }, ) - cy.logout() + if (app === "mobile") { + cy.visit(MOBILE_URL + "/Home") + cy.get('*[data-testid="logout"]').click() + } else { + cy.logout() + } // There are verification emails from the registration process in the inbox that we need to deleted // for the assertions below to pass. @@ -213,19 +346,39 @@ context("Registration success with code method", () => { // Attempt to sign in with email 2 (should fail) cy.visit(login) - cy.get(`input[name=identifier]`).type(email2) + cy.get(Selectors[app]["identifier"]).type(email2) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["submitCode"]).click() cy.getLoginCodeFromEmail(email2, { expectedCount: 1, }).should((code) => { - cy.get(`input[name=code]`).type(code) - cy.get("button[name=method][value=code]").click() + cy.get(Selectors[app]["code"]).type(code) + cy.get(Selectors[app]["submitCode"]).click() }) - cy.getSession().should((session) => { - const { identity } = session + if (app === "mobile") { + cy.get('[data-testid="session-token"]').then((token) => { + cy.getSession({ + expectAal: "aal1", + expectMethods: ["code"], + token: token.text(), + }).then((session) => { + cy.wrap(session).as("session") + }) + }) + + cy.get('[data-testid="session-content"]').should("contain", email) + cy.get('[data-testid="session-token"]').should("not.be.empty") + } else { + cy.getSession({ expectAal: "aal1", expectMethods: ["code"] }).then( + (session) => { + cy.wrap(session).as("session") + }, + ) + } + + cy.get("@session").then(({ identity }) => { expect(identity.id).to.not.be.empty expect(identity.verifiable_addresses).to.have.length(2) expect( diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index 1db5571f2900..f8f2b7ad0615 100644 --- a/test/e2e/cypress/support/commands.ts +++ b/test/e2e/cypress/support/commands.ts @@ -1028,18 +1028,31 @@ Cypress.Commands.add("deleteMail", ({ atLeast = 0 } = {}) => { Cypress.Commands.add( "getSession", - ({ expectAal = "aal1", expectMethods = [] } = {}) => { + ({ expectAal = "aal1", expectMethods = [], token } = {}) => { // Do the request once to ensure we have a session (with retry) cy.request({ method: "GET", url: `${KRATOS_PUBLIC}/sessions/whoami`, + ...(token && { + auth: { + bearer: token, + }, + }), }) .its("status") // adds retry .should("eq", 200) // Return the session for further propagation return cy - .request("GET", `${KRATOS_PUBLIC}/sessions/whoami`) + .request({ + method: "GET", + url: `${KRATOS_PUBLIC}/sessions/whoami`, + ...(token && { + auth: { + bearer: token, + }, + }), + }) .then((response) => { expect(response.body.id).to.not.be.empty expect(dayjs().isBefore(dayjs(response.body.expires_at))).to.be.true @@ -1156,6 +1169,7 @@ Cypress.Commands.add( const code = extractRecoveryCode(message.body) expect(code).to.not.be.undefined expect(code.length).to.equal(6) + cy.wrap(code).as("recoveryCode") if (enterCode) { cy.get("input[name='code']").type(code) } @@ -1313,9 +1327,14 @@ Cypress.Commands.add("submitProfileForm", () => { cy.get('[name="method"][value="profile"]:disabled').should("not.exist") }) -Cypress.Commands.add("submitCodeForm", () => { - cy.get('button[name="method"][value="code"]').click() - cy.get('button[name="method"][value="code"]:disabled').should("not.exist") +Cypress.Commands.add("submitCodeForm", (app) => { + if (app === "mobile") { + cy.get('[data-testid="field/method/code"]').click() + cy.get('[data-testid="field/method/code"]:disabled').should("not.exist") + } else { + cy.get('button[name="method"][value="code"]').click() + cy.get('button[name="method"][value="code"]:disabled').should("not.exist") + } }) Cypress.Commands.add("clickWebAuthButton", (type: string) => { diff --git a/test/e2e/cypress/support/index.d.ts b/test/e2e/cypress/support/index.d.ts index 180e93260c41..1792db4a51cc 100644 --- a/test/e2e/cypress/support/index.d.ts +++ b/test/e2e/cypress/support/index.d.ts @@ -40,6 +40,7 @@ declare global { expectMethods?: Array< "password" | "webauthn" | "lookup_secret" | "totp" | "code" > + token?: string }): Chainable /** @@ -362,7 +363,7 @@ declare global { /** * Submits a code form by clicking the button with method=code */ - submitCodeForm(): Chainable + submitCodeForm(app: "mobile" | "express" | "react"): Chainable /** * Expect a CSRF error to occur diff --git a/test/e2e/run.sh b/test/e2e/run.sh index cb2d813ddad0..a5b405fe1d2e 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -204,31 +204,23 @@ prepare() { PORT=4746 HYDRA_ADMIN_URL=http://localhost:4745 ./hydra-kratos-login-consent >"${base}/test/e2e/hydra-kratos-ui.e2e.log" 2>&1 & ) - if [ -z ${NODE_UI_PATH+x} ]; then - ( - cd "$node_ui_dir" - PORT=4456 SECURITY_MODE=cookie npm run serve \ - >"${base}/test/e2e/ui-node.e2e.log" 2>&1 & - ) - else - ( - cd "$node_ui_dir" - PORT=4456 SECURITY_MODE=cookie npm run start \ - >"${base}/test/e2e/ui-node.e2e.log" 2>&1 & - ) - fi + ( + cd "$node_ui_dir" + PORT=4456 SECURITY_MODE=cookie npm run start \ + >"${base}/test/e2e/ui-node.e2e.log" 2>&1 & + ) if [ -z ${REACT_UI_PATH+x} ]; then ( cd "$react_ui_dir" - ORY_KRATOS_URL=http://localhost:4433 npm run build - ORY_KRATOS_URL=http://localhost:4433 npm run start -- --hostname 0.0.0.0 --port 4458 \ + NEXT_PUBLIC_KRATOS_PUBLIC_URL=http://localhost:4433 npm run build + NEXT_PUBLIC_KRATOS_PUBLIC_URL=http://localhost:4433 npm run start -- --hostname 127.0.0.1 --port 4458 \ >"${base}/test/e2e/react-iu.e2e.log" 2>&1 & ) else ( cd "$react_ui_dir" - PORT=4458 ORY_KRATOS_URL=http://localhost:4433 npm run dev \ + PORT=4458 NEXT_PUBLIC_KRATOS_PUBLIC_URL=http://localhost:4433 npm run dev \ >"${base}/test/e2e/react-iu.e2e.log" 2>&1 & ) fi