From 57eff8b7607074fc0458773631cc15feaa9c8ad8 Mon Sep 17 00:00:00 2001 From: Jan Cernik <66427541+jancernik@users.noreply.github.com> Date: Fri, 17 May 2024 11:56:43 -0300 Subject: [PATCH] DEV: Full system specs coverage for signup/login (#26977) - login with username/password - login with username/password and 2FA - login with username/password back up code - login with magic link - login with magic link and 2FA - login with magic link and back up code - login when 2FA is required - reset password --- - signup and activate account - signup with invite code - signup with invite link - signup and approve account - signup and auto approve account - signup with blocked domain --- - basic login with Facebook - basic login with Google - basic login with Github - basic login with Twitter - basic login with Discord - basic login with Linkedin --- spec/support/omniauth_helpers.rb | 88 ++++++++++++ spec/system/login_spec.rb | 159 ++++++++++++++++++++++ spec/system/page_objects/modals/login.rb | 69 ++++++++++ spec/system/page_objects/modals/signup.rb | 101 ++++++++++++++ spec/system/signup_spec.rb | 150 ++++++++++++++++++++ spec/system/social_authentication_spec.rb | 129 ++++++++++++++++++ 6 files changed, 696 insertions(+) create mode 100644 spec/support/omniauth_helpers.rb create mode 100644 spec/system/login_spec.rb create mode 100644 spec/system/page_objects/modals/signup.rb create mode 100644 spec/system/signup_spec.rb create mode 100644 spec/system/social_authentication_spec.rb diff --git a/spec/support/omniauth_helpers.rb b/spec/support/omniauth_helpers.rb new file mode 100644 index 00000000000..d78383a7b55 --- /dev/null +++ b/spec/support/omniauth_helpers.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module OmniauthHelpers + FIRST_NAME = "John" + LAST_NAME = "Doe" + FULL_NAME = "John Doe" + USERNAME = "john" + EMAIL = "johndoe@example.com" + + def mock_facebook_auth(email: EMAIL, name: FULL_NAME) + OmniAuth.config.mock_auth[:facebook] = OmniAuth::AuthHash.new( + provider: "facebook", + uid: "12345", + info: OmniAuth::AuthHash::InfoHash.new(email: email, name: name), + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:facebook] + end + + def mock_google_auth(email: EMAIL, name: FULL_NAME, verified: true) + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( + provider: "google_oauth2", + uid: "12345", + info: OmniAuth::AuthHash::InfoHash.new(email: email, name: name), + extra: { + raw_info: { + email_verified: verified, + }, + }, + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] + end + + def mock_github_auth(email: EMAIL, nickname: USERNAME, name: FULL_NAME, verified: true) + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new( + provider: "github", + uid: "12345", + info: OmniAuth::AuthHash::InfoHash.new(email: email, nickname: nickname, name: name), + extra: { + all_emails: [{ email: email, primary: true, verified: verified, visibility: "private" }], + }, + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:github] + end + + def mock_twitter_auth(nickname: USERNAME, name: FULL_NAME, verified: true) + OmniAuth.config.mock_auth[:twitter] = OmniAuth::AuthHash.new( + provider: "twitter", + uid: "12345", + info: OmniAuth::AuthHash::InfoHash.new(nickname: nickname, name: name), + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:twitter] + end + + def mock_discord_auth(email: EMAIL, username: USERNAME, name: FULL_NAME) + OmniAuth.config.mock_auth[:discord] = OmniAuth::AuthHash.new( + provider: "discord", + uid: "12345", + info: OmniAuth::AuthHash::InfoHash.new(email: email, name: name), + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:discord] + end + + def mock_linkedin_auth(email: EMAIL, first_name: FIRST_NAME, last_name: LAST_NAME) + OmniAuth.config.mock_auth[:linkedin_oidc] = OmniAuth::AuthHash.new( + provider: "linkedin_oidc", + uid: "12345", + info: + OmniAuth::AuthHash::InfoHash.new( + email: email, + first_name: first_name, + last_name: last_name, + ), + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:linkedin_oidc] + end + + def reset_omniauth_config(provider) + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[provider] = nil + Rails.application.env_config["omniauth.auth"] = nil + end +end diff --git a/spec/system/login_spec.rb b/spec/system/login_spec.rb new file mode 100644 index 00000000000..1ba11d2ec31 --- /dev/null +++ b/spec/system/login_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "rotp" + +describe "Login", type: :system do + let(:login_modal) { PageObjects::Modals::Login.new } + fab!(:user) { Fabricate(:user, username: "john", password: "supersecurepassword") } + + before { Jobs.run_immediately! } + + context "with username and password" do + it "can login" do + EmailToken.confirm(Fabricate(:email_token, user: user).token) + + login_modal.open + login_modal.fill(username: "john", password: "supersecurepassword") + login_modal.click_login + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can login and activate account" do + login_modal.open + login_modal.fill(username: "john", password: "supersecurepassword") + login_modal.click_login + find(".activation-controls button.resend").click + + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly(user.email) + activation_link = mail.body.to_s[%r{/u/activate-account/\S+}] + visit activation_link + + find("#activate-account-button").click + + visit "/" + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can reset password" do + login_modal.open + login_modal.fill_username("john") + login_modal.forgot_password + find("button.forgot-password-reset").click + + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly(user.email) + reset_password_link = mail.body.to_s[%r{/u/password-reset/\S+}] + visit reset_password_link + + find("#new-account-password").fill_in(with: "newsuperpassword") + find("form .btn-primary").click + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + + context "with login link" do + it "can login" do + login_modal.open + login_modal.fill_username("john") + login_modal.email_login_link + + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly(user.email) + login_link = mail.body.to_s[%r{/session/email-login/\S+}] + visit login_link + + find(".email-login-form .btn-primary").click + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + + context "with two-factor authentication" do + let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) } + let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) } + fab!(:other_user) { Fabricate(:user, username: "jane", password: "supersecurepassword") } + + before do + EmailToken.confirm(Fabricate(:email_token, user: user).token) + EmailToken.confirm(Fabricate(:email_token, user: other_user).token) + end + + context "when it is required" do + before { SiteSetting.enforce_second_factor = "all" } + + it "requires to set 2FA after login" do + login_modal.open + login_modal.fill(username: "jane", password: "supersecurepassword") + login_modal.click_login + expect(page).to have_css(".header-dropdown-toggle.current-user") + expect(page).to have_content(I18n.t("js.user.second_factor.enforced_notice")) + end + end + + it "can login with totp" do + login_modal.open + login_modal.fill(username: "john", password: "supersecurepassword") + login_modal.click_login + expect(page).to have_css(".login-modal-body.second-factor") + + totp = ROTP::TOTP.new(user_second_factor.data).now + find("#login-second-factor").fill_in(with: totp) + login_modal.click_login + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can login with backup code" do + login_modal.open + login_modal.fill(username: "john", password: "supersecurepassword") + login_modal.click_login + expect(page).to have_css(".login-modal-body.second-factor") + + find(".toggle-second-factor-method").click + find(".second-factor-token-input").fill_in(with: "iAmValidBackupCode") + login_modal.click_login + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can login with login link and totp" do + login_modal.open + login_modal.fill_username("john") + login_modal.email_login_link + + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly(user.email) + login_link = mail.body.to_s[%r{/session/email-login/\S+}] + visit login_link + + totp = ROTP::TOTP.new(user_second_factor.data).now + find(".second-factor-token-input").fill_in(with: totp) + find(".email-login-form .btn-primary").click + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can login with login link and backup code" do + login_modal.open + login_modal.fill_username("john") + login_modal.email_login_link + + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly(user.email) + login_link = mail.body.to_s[%r{/session/email-login/\S+}] + visit login_link + + find(".toggle-second-factor-method").click + find(".second-factor-token-input").fill_in(with: "iAmValidBackupCode") + find(".email-login-form .btn-primary").click + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end +end diff --git a/spec/system/page_objects/modals/login.rb b/spec/system/page_objects/modals/login.rb index 81d3fe1e686..96a6b0a53c8 100644 --- a/spec/system/page_objects/modals/login.rb +++ b/spec/system/page_objects/modals/login.rb @@ -6,6 +6,75 @@ module PageObjects def open? super && has_css?(".login-modal") end + + def closed? + super && has_no_css?(".login-modal") + end + + def open + visit("/login") + end + + def open_from_header + find(".login-button").click + end + + def open_signup + find("#new-account-link").click + end + + def click_login + find("#login-button").click + end + + def email_login_link + find("#email-login-link").click + end + + def forgot_password + find("#forgot-password-link").click + end + + def fill_username(username) + find("#login-account-name").fill_in(with: username) + end + + def fill_password(password) + find("#login-account-password").fill_in(with: password) + end + + def fill(username: nil, password: nil) + fill_username(username) if username + fill_password(password) if password + end + + def select_facebook + find(".btn-social.facebook").click + end + + def select_google + find(".btn-social.google_oauth2").click + end + + def select_github + find(".btn-social.github").click + end + + def select_twitter + find(".btn-social.twitter").click + end + + def select_discord + find(".btn-social.discord").click + end + + def select_linkedin + find(".btn-social.linkedin_oidc").click + end + + def select_passkey + find(".btn-social.passkey-login-button").click + end end end end diff --git a/spec/system/page_objects/modals/signup.rb b/spec/system/page_objects/modals/signup.rb new file mode 100644 index 00000000000..6dc67b30cb2 --- /dev/null +++ b/spec/system/page_objects/modals/signup.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module PageObjects + module Modals + class Signup < PageObjects::Modals::Base + def open? + super && has_css?(".modal.create-account") + end + + def closed? + super && has_no_css?(".modal.create-account") + end + + def open + visit("/signup") + end + + def open_from_header + find(".sign-up-button").click + end + + def open_login + find("#login-link").click + end + + def click_create_account + find(".modal.create-account .btn-primary").click + end + + def has_password_input? + has_css?("#new-account-password") + end + + def has_no_password_input? + has_no_css?("#new-account-password") + end + + def fill_email(email) + find("#new-account-email").fill_in(with: email) + end + + def fill_username(username) + find("#new-account-username").fill_in(with: username) + end + + def fill_name(name) + find("#new-account-name").fill_in(with: name) + end + + def fill_password(password) + find("#new-account-password").fill_in(with: password) + end + + def fill_code(code) + find("#inviteCode").fill_in(with: code) + end + + def has_valid_email? + find(".create-account-email").has_css?("#account-email-validation.good") + end + + def has_valid_username? + find(".create-account__username").has_css?("#username-validation.good") + end + + def has_valid_password? + find(".create-account__password").has_css?("#password-validation.good") + end + + def has_valid_fields? + has_valid_email? + has_valid_username? + has_valid_password? + end + + def select_facebook + find(".btn-social.facebook").click + end + + def select_google + find(".btn-social.google_oauth2").click + end + + def select_github + find(".btn-social.github").click + end + + def select_twitter + find(".btn-social.twitter").click + end + + def select_discord + find(".btn-social.discord").click + end + + def select_linkedin + find(".btn-social.linkedin_oidc").click + end + end + end +end diff --git a/spec/system/signup_spec.rb b/spec/system/signup_spec.rb new file mode 100644 index 00000000000..b342613d5e1 --- /dev/null +++ b/spec/system/signup_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +describe "Signup", type: :system do + let(:login_modal) { PageObjects::Modals::Login.new } + let(:signup_modal) { PageObjects::Modals::Signup.new } + + context "when anyone can create an account" do + it "can signup and activate account" do + Jobs.run_immediately! + + signup_modal.open + signup_modal.fill_email("johndoe@example.com") + signup_modal.fill_username("john") + signup_modal.fill_password("supersecurepassword") + expect(signup_modal).to have_valid_fields + signup_modal.click_create_account + + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly("johndoe@example.com") + activation_link = mail.body.to_s[%r{/u/activate-account/\S+}] + + visit "/" + visit activation_link + find("#activate-account-button").click + + visit "/" + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + context "with invite code" do + before { SiteSetting.invite_code = "cupcake" } + + it "can signup with valid code" do + signup_modal.open + signup_modal.fill_email("johndoe@example.com") + signup_modal.fill_username("john") + signup_modal.fill_password("supersecurepassword") + signup_modal.fill_code("cupcake") + expect(signup_modal).to have_valid_fields + + signup_modal.click_create_account + expect(page).to have_css(".account-created") + end + + it "cannot signup with invalid code" do + signup_modal.open + signup_modal.fill_email("johndoe@example.com") + signup_modal.fill_username("john") + signup_modal.fill_password("supersecurepassword") + signup_modal.fill_code("pudding") + expect(signup_modal).to have_valid_fields + + signup_modal.click_create_account + expect(signup_modal).to have_content(I18n.t("login.wrong_invite_code")) + expect(signup_modal).to have_no_css(".account-created") + end + end + + context "when user requires approval" do + before do + SiteSetting.must_approve_users = true + SiteSetting.auto_approve_email_domains = "awesomeemail.com" + end + + it "can signup but cannot login until approval" do + signup_modal.open + signup_modal.fill_email("johndoe@example.com") + signup_modal.fill_username("john") + signup_modal.fill_password("supersecurepassword") + expect(signup_modal).to have_valid_fields + signup_modal.click_create_account + + visit "/" + login_modal.open + login_modal.fill_username("john") + login_modal.fill_password("supersecurepassword") + login_modal.click_login + expect(login_modal).to have_content(I18n.t("login.not_approved")) + + wait_for(timeout: 5) { User.find_by(username: "john") != nil } + user = User.find_by(username: "john") + user.update!(approved: true) + EmailToken.confirm(Fabricate(:email_token, user: user).token) + + login_modal.click_login + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can login directly when using an auto approved email" do + signup_modal.open + signup_modal.fill_email("johndoe@awesomeemail.com") + signup_modal.fill_username("john") + signup_modal.fill_password("supersecurepassword") + expect(signup_modal).to have_valid_fields + signup_modal.click_create_account + + wait_for(timeout: 5) { User.find_by(username: "john") != nil } + user = User.find_by(username: "john") + EmailToken.confirm(Fabricate(:email_token, user: user).token) + + login_modal.open + login_modal.fill_username("john") + login_modal.fill_password("supersecurepassword") + login_modal.click_login + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + end + + context "when the email domain is blocked" do + before { SiteSetting.blocked_email_domains = "example.com" } + + it "cannot signup" do + signup_modal.open + signup_modal.fill_email("johndoe@example.com") + signup_modal.fill_username("john") + signup_modal.fill_password("supersecurepassword") + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_password + expect(signup_modal).to have_content(I18n.t("user.email.not_allowed")) + end + end + + context "when site is invite only" do + before { SiteSetting.invite_only = true } + + it "cannot open the signup modal" do + signup_modal.open + expect(signup_modal).to be_closed + expect(page).to have_no_css(".sign-up-button") + + login_modal.open_from_header + expect(login_modal).to have_no_css("#new-account-link") + end + + it "can signup with invite link" do + invite = Fabricate(:invite, email: "johndoe@example.com") + visit "/invites/#{invite.invite_key}?t=#{invite.email_token}" + + find("#new-account-password").fill_in(with: "supersecurepassword") + find(".username-input").has_css?("#username-validation.good") + find(".create-account__password-tip-validation").has_css?("#password-validation.good") + find(".invitation-cta__accept").click + + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end +end diff --git a/spec/system/social_authentication_spec.rb b/spec/system/social_authentication_spec.rb new file mode 100644 index 00000000000..f1f364498d9 --- /dev/null +++ b/spec/system/social_authentication_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +describe "Social authentication", type: :system do + include OmniauthHelpers + + let(:login_modal) { PageObjects::Modals::Login.new } + let(:signup_modal) { PageObjects::Modals::Signup.new } + + before { OmniAuth.config.test_mode = true } + + context "for Facebook" do + before { SiteSetting.enable_facebook_logins = true } + after { reset_omniauth_config(:facebook) } + + it "works" do + mock_facebook_auth + visit("/") + + login_modal.open + login_modal.select_facebook + expect(signup_modal).to be_open + expect(signup_modal).to have_no_password_input + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_email + signup_modal.click_create_account + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + + context "for Google" do + before { SiteSetting.enable_google_oauth2_logins = true } + after { reset_omniauth_config(:google_oauth2) } + + it "works" do + mock_google_auth + visit("/") + + login_modal.open_from_header + login_modal.select_google + expect(signup_modal).to be_open + expect(signup_modal).to have_no_password_input + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_email + signup_modal.click_create_account + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + + context "for Github" do + before { SiteSetting.enable_github_logins = true } + after { reset_omniauth_config(:github) } + + it "works" do + mock_github_auth + visit("/") + + login_modal.open + login_modal.select_github + expect(signup_modal).to be_open + expect(signup_modal).to have_no_password_input + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_email + signup_modal.click_create_account + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + + context "for Twitter" do + before { SiteSetting.enable_twitter_logins = true } + after { reset_omniauth_config(:twitter) } + + it "works" do + mock_twitter_auth + visit("/") + + login_modal.open + login_modal.select_twitter + expect(signup_modal).to be_open + expect(signup_modal).to have_no_password_input + signup_modal.fill_email(OmniauthHelpers::EMAIL) + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_email + signup_modal.click_create_account + expect(page).to have_css(".account-created") + end + end + + context "for Discord" do + before { SiteSetting.enable_discord_logins = true } + after { reset_omniauth_config(:discord) } + + it "works" do + mock_discord_auth + visit("/") + + login_modal.open + login_modal.select_discord + expect(signup_modal).to be_open + expect(signup_modal).to have_no_password_input + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_email + signup_modal.click_create_account + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end + + context "for Linkedin" do + before do + SiteSetting.linkedin_oidc_client_id = "12345" + SiteSetting.linkedin_oidc_client_secret = "abcde" + SiteSetting.enable_linkedin_oidc_logins = true + end + after { reset_omniauth_config(:linkedin_oidc) } + + it "works" do + mock_linkedin_auth + visit("/") + + login_modal.open + login_modal.select_linkedin + expect(signup_modal).to be_open + expect(signup_modal).to have_no_password_input + expect(signup_modal).to have_valid_username + expect(signup_modal).to have_valid_email + signup_modal.click_create_account + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + end +end