# frozen_string_literal: true

require "rotp"

RSpec.describe SessionController do
  let(:user) { Fabricate(:user) }
  let(:email_token) { Fabricate(:email_token, user: user) }

  fab!(:admin)
  let(:admin_email_token) { Fabricate(:email_token, user: admin) }

  shared_examples "failed to continue local login" do
    it "should return the right response" do
      expect(response).not_to be_successful
      expect(response.status).to eq(403)
    end
  end

  describe "#email_login_info" do
    let(:email_token) do
      Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login])
    end

    before { SiteSetting.enable_local_logins_via_email = true }

    context "when local logins via email disabled" do
      before { SiteSetting.enable_local_logins_via_email = false }

      it "only works for admins" do
        get "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(403)

        user.update(admin: true)
        get "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
      end
    end

    context "when SSO enabled" do
      before do
        SiteSetting.discourse_connect_url = "https://www.example.com/sso"
        SiteSetting.enable_discourse_connect = true
      end

      it "only works for admins" do
        get "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(403)

        user.update(admin: true)
        get "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
      end
    end

    context "with missing token" do
      it "returns the right response" do
        get "/session/email-login"
        expect(response.status).to eq(404)
      end
    end

    context "with valid token" do
      it "returns information" do
        get "/session/email-login/#{email_token.token}.json"

        expect(response.parsed_body["can_login"]).to eq(true)
        expect(response.parsed_body["second_factor_required"]).to eq(nil)

        # Does not log in the user
        expect(session[:current_user_id]).to be_nil
      end

      it "fails when local logins via email is disabled" do
        SiteSetting.enable_local_logins_via_email = false

        get "/session/email-login/#{email_token.token}.json"

        expect(response.status).to eq(403)
      end

      it "fails when local logins is disabled" do
        SiteSetting.enable_local_logins = false

        get "/session/email-login/#{email_token.token}.json"

        expect(response.status).to eq(403)
      end

      context "when user has 2-factor logins" do
        let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
        let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }

        it "includes that information in the response" do
          get "/session/email-login/#{email_token.token}.json"

          response_body_parsed = response.parsed_body
          expect(response_body_parsed["can_login"]).to eq(true)
          expect(response_body_parsed["second_factor_required"]).to eq(true)
          expect(response_body_parsed["backup_codes_enabled"]).to eq(true)
        end
      end

      context "when user has security key enabled" do
        let!(:user_security_key) { Fabricate(:user_security_key, user: user) }

        it "includes that information in the response" do
          get "/session/email-login/#{email_token.token}.json"

          response_body_parsed = response.parsed_body
          expect(response_body_parsed["can_login"]).to eq(true)
          expect(response_body_parsed["security_key_required"]).to eq(true)
          expect(response_body_parsed["second_factor_required"]).to eq(nil)
          expect(response_body_parsed["backup_codes_enabled"]).to eq(nil)
          expect(response_body_parsed["allowed_credential_ids"]).to eq(
            [user_security_key.credential_id],
          )
          secure_session = SecureSession.new(session["secure_session_id"])

          expect(response_body_parsed["challenge"]).to eq(
            DiscourseWebauthn.challenge(user, secure_session),
          )
          expect(DiscourseWebauthn.rp_id).to eq("localhost")
        end
      end
    end
  end

  describe "#email_login" do
    let(:email_token) do
      Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login])
    end

    before { SiteSetting.enable_local_logins_via_email = true }

    context "when in staff writes only mode" do
      use_redis_snapshotting

      before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }

      it "allows admins to login" do
        user.update!(admin: true)
        post "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(200)
        expect(session[:current_user_id]).to eq(user.id)
      end

      it "does not allow other users to login" do
        post "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(503)
        expect(session[:current_user_id]).to eq(nil)
      end
    end

    context "when local logins via email disabled" do
      before { SiteSetting.enable_local_logins_via_email = false }

      it "only works for admins" do
        post "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(403)

        user.update(admin: true)
        post "/session/email-login/#{email_token.token}.json"
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        expect(session[:current_user_id]).to eq(user.id)
      end
    end

    context "with missing token" do
      it "returns the right response" do
        post "/session/email-login"
        expect(response.status).to eq(404)
      end
    end

    context "with invalid token" do
      it "returns the right response" do
        post "/session/email-login/adasdad.json"

        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).to eq(
          I18n.t("email_login.invalid_token", base_url: Discourse.base_url),
        )
      end

      context "when token has expired" do
        it "should return the right response" do
          email_token.update!(created_at: 999.years.ago)

          post "/session/email-login/#{email_token.token}.json"

          expect(response.status).to eq(200)

          expect(response.parsed_body["error"]).to eq(
            I18n.t("email_login.invalid_token", base_url: Discourse.base_url),
          )
        end
      end
    end

    context "with valid token" do
      it "returns success" do
        post "/session/email-login/#{email_token.token}.json"

        expect(response.parsed_body["success"]).to eq("OK")
        expect(session[:current_user_id]).to eq(user.id)
      end

      it "fails when local logins via email is disabled" do
        SiteSetting.enable_local_logins_via_email = false

        post "/session/email-login/#{email_token.token}.json"

        expect(response.status).to eq(403)
        expect(session[:current_user_id]).to eq(nil)
      end

      it "fails when local logins is disabled" do
        SiteSetting.enable_local_logins = false

        post "/session/email-login/#{email_token.token}.json"

        expect(response.status).to eq(403)
        expect(session[:current_user_id]).to eq(nil)
      end

      it "doesn't log in the user when not approved" do
        SiteSetting.must_approve_users = true

        post "/session/email-login/#{email_token.token}.json"

        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).to eq(I18n.t("login.not_approved"))
        expect(session[:current_user_id]).to eq(nil)
      end

      context "when admin IP address is not valid" do
        before do
          Fabricate(
            :screened_ip_address,
            ip_address: "111.111.11.11",
            action_type: ScreenedIpAddress.actions[:allow_admin],
          )

          SiteSetting.use_admin_ip_allowlist = true
          user.update!(admin: true)
        end

        it "returns the right response" do
          post "/session/email-login/#{email_token.token}.json"

          expect(response.status).to eq(200)

          expect(response.parsed_body["error"]).to eq(
            I18n.t("login.admin_not_allowed_from_ip_address", username: user.username),
          )
          expect(session[:current_user_id]).to eq(nil)
        end
      end

      context "when IP address is blocked" do
        let(:permitted_ip_address) { "111.234.23.11" }

        before do
          Fabricate(
            :screened_ip_address,
            ip_address: permitted_ip_address,
            action_type: ScreenedIpAddress.actions[:block],
          )
        end

        it "returns the right response" do
          ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(permitted_ip_address)

          post "/session/email-login/#{email_token.token}.json"

          expect(response.status).to eq(200)

          expect(response.parsed_body["error"]).to eq(
            I18n.t("login.not_allowed_from_ip_address", username: user.username),
          )
          expect(session[:current_user_id]).to eq(nil)
        end
      end

      context "when timezone param is provided" do
        it "sets the user_option timezone for the user" do
          post "/session/email-login/#{email_token.token}.json",
               params: {
                 timezone: "Australia/Melbourne",
               }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          expect(user.reload.user_option.timezone).to eq("Australia/Melbourne")
        end
      end

      it "fails when user is suspended" do
        user.update!(suspended_till: 2.days.from_now, suspended_at: Time.zone.now)

        post "/session/email-login/#{email_token.token}.json"

        expect(response.status).to eq(200)

        expect(response.parsed_body["error"]).to eq(
          I18n.t("login.suspended", date: I18n.l(user.suspended_till, format: :date_only)),
        )
        expect(session[:current_user_id]).to eq(nil)
      end

      context "when user has 2-factor logins" do
        let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
        let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }

        describe "errors on incorrect 2-factor" do
          context "when using totp method" do
            it "does not log in with incorrect two factor" do
              post "/session/email-login/#{email_token.token}.json",
                   params: {
                     second_factor_token: "0000",
                     second_factor_method: UserSecondFactor.methods[:totp],
                   }

              expect(response.status).to eq(200)

              expect(response.parsed_body["error"]).to eq(
                I18n.t("login.invalid_second_factor_code"),
              )
              expect(session[:current_user_id]).to eq(nil)
            end
          end
          context "when using backup code method" do
            it "does not log in with incorrect backup code" do
              post "/session/email-login/#{email_token.token}.json",
                   params: {
                     second_factor_token: "0000",
                     second_factor_method: UserSecondFactor.methods[:backup_codes],
                   }

              expect(response.status).to eq(200)
              expect(response.parsed_body["error"]).to eq(
                I18n.t("login.invalid_second_factor_code"),
              )
              expect(session[:current_user_id]).to eq(nil)
            end
          end
        end

        describe "allows successful 2-factor" do
          context "when using totp method" do
            it "logs in correctly" do
              post "/session/email-login/#{email_token.token}.json",
                   params: {
                     second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
                     second_factor_method: UserSecondFactor.methods[:totp],
                   }

              expect(response.parsed_body["success"]).to eq("OK")
              expect(session[:current_user_id]).to eq(user.id)
            end
          end
          context "when using backup code method" do
            it "logs in correctly" do
              post "/session/email-login/#{email_token.token}.json",
                   params: {
                     second_factor_token: "iAmValidBackupCode",
                     second_factor_method: UserSecondFactor.methods[:backup_codes],
                   }

              expect(response.parsed_body["success"]).to eq("OK")
              expect(session[:current_user_id]).to eq(user.id)
            end
          end
        end

        context "if the security_key_param is provided but only TOTP is enabled" do
          it "does not log in the user" do
            post "/session/email-login/#{email_token.token}.json",
                 params: {
                   second_factor_token: "foo",
                   second_factor_method: UserSecondFactor.methods[:totp],
                 }

            expect(response.status).to eq(200)

            expect(response.parsed_body["error"]).to eq(I18n.t("login.invalid_second_factor_code"))
            expect(session[:current_user_id]).to eq(nil)
          end
        end
      end

      context "when user has only security key enabled" do
        let!(:user_security_key) do
          Fabricate(
            :user_security_key,
            user: user,
            credential_id: valid_security_key_data[:credential_id],
            public_key: valid_security_key_data[:public_key],
          )
        end

        before do
          simulate_localhost_webauthn_challenge
          DiscourseWebauthn.stubs(:origin).returns("http://localhost:3000")

          # store challenge in secure session by visiting the email login page
          get "/session/email-login/#{email_token.token}.json"
        end

        context "when the security key params are blank and a random second factor token is provided" do
          it "shows an error message and denies login" do
            post "/session/email-login/#{email_token.token}.json",
                 params: {
                   second_factor_token: "XXXXXXX",
                   second_factor_method: UserSecondFactor.methods[:totp],
                 }

            expect(response.status).to eq(200)
            expect(session[:current_user_id]).to eq(nil)
            response_body = response.parsed_body
            expect(response_body["error"]).to eq(I18n.t("login.not_enabled_second_factor_method"))
          end
        end

        context "when the security key params are invalid" do
          it "shows an error message and denies login" do
            post "/session/email-login/#{email_token.token}.json",
                 params: {
                   second_factor_token: {
                     signature: "bad_sig",
                     clientData: "bad_clientData",
                     credentialId: "bad_credential_id",
                     authenticatorData: "bad_authenticator_data",
                   },
                   second_factor_method: UserSecondFactor.methods[:security_key],
                 }

            expect(response.status).to eq(200)
            expect(session[:current_user_id]).to eq(nil)
            response_body = response.parsed_body
            expect(response_body["failed"]).to eq("FAILED")
            expect(response_body["error"]).to eq(I18n.t("webauthn.validation.not_found_error"))
          end
        end

        context "when the security key params are valid" do
          it "logs the user in" do
            post "/session/email-login/#{email_token.token}.json",
                 params: {
                   login: user.username,
                   password: "myawesomepassword",
                   second_factor_token: valid_security_key_auth_post_data,
                   second_factor_method: UserSecondFactor.methods[:security_key],
                 }

            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).not_to be_present
            user.reload

            expect(session[:current_user_id]).to eq(user.id)
            expect(user.user_auth_tokens.count).to eq(1)
          end
        end
      end

      context "when user has security key and totp enabled" do
        let!(:user_security_key) do
          Fabricate(
            :user_security_key,
            user: user,
            credential_id: valid_security_key_data[:credential_id],
            public_key: valid_security_key_data[:public_key],
          )
        end
        let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }

        it "doesnt allow logging in if the 2fa params are garbled" do
          post "/session/email-login/#{email_token.token}.json",
               params: {
                 second_factor_method: UserSecondFactor.methods[:totp],
                 second_factor_token: "blah",
               }

          expect(response.status).to eq(200)
          expect(session[:current_user_id]).to eq(nil)
          response_body = response.parsed_body
          expect(response_body["error"]).to eq(I18n.t("login.invalid_second_factor_code"))
        end

        it "doesnt allow login if both of the 2fa params are blank" do
          post "/session/email-login/#{email_token.token}.json",
               params: {
                 second_factor_method: UserSecondFactor.methods[:totp],
                 second_factor_token: "",
               }

          expect(response.status).to eq(200)
          expect(session[:current_user_id]).to eq(nil)
          response_body = response.parsed_body
          expect(response_body["error"]).to eq(I18n.t("login.invalid_second_factor_code"))
        end
      end
    end
  end

  describe "logoff support" do
    it "can log off users cleanly" do
      user = Fabricate(:user)
      sign_in(user)

      UserAuthToken.destroy_all

      # we need a route that will call current user
      post "/drafts.json", params: {}
      expect(response.headers["Discourse-Logged-Out"]).to eq("1")
    end
  end

  describe "#become" do
    let!(:user) { Fabricate(:user) }

    it "does not work when in production mode" do
      Rails.env.stubs(:production?).returns(true)
      get "/session/#{user.username}/become.json"

      expect(response.status).to eq(403)
      expect(response.parsed_body["error_type"]).to eq("invalid_access")
      expect(session[:current_user_id]).to be_blank
    end

    it "works in development mode" do
      Rails.env.stubs(:development?).returns(true)
      get "/session/#{user.username}/become.json"
      expect(response).to be_redirect
      expect(session[:current_user_id]).to eq(user.id)
    end
  end

  describe "#sso" do
    before do
      SiteSetting.discourse_connect_url = "http://example.com/discourse_sso"
      SiteSetting.enable_discourse_connect = true
      SiteSetting.discourse_connect_secret = "shjkfdhsfkjh"
    end

    it "redirects correctly" do
      get "/session/sso"
      expect(response.status).to eq(302)
      expect(response.location).to start_with(SiteSetting.discourse_connect_url)
    end
  end

  describe "#sso_login" do
    before do
      @sso_url = "http://example.com/discourse_sso"
      @sso_secret = "shjkfdhsfkjh"

      SiteSetting.discourse_connect_url = @sso_url
      SiteSetting.enable_discourse_connect = true
      SiteSetting.discourse_connect_secret = @sso_secret

      Fabricate(:admin)
    end

    let(:headers) { { host: Discourse.current_hostname } }

    def get_sso(return_path)
      nonce = SecureRandom.hex
      dso = DiscourseConnect.new(secure_session: read_secure_session)
      dso.nonce = nonce
      dso.register_nonce(return_path)

      sso = DiscourseConnectBase.new
      sso.nonce = nonce
      sso.sso_secret = @sso_secret
      sso
    end

    context "when in staff writes only mode" do
      use_redis_snapshotting

      before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }

      it "allows staff to login" do
        sso = get_sso("/a/")
        sso.external_id = "666"
        sso.email = "bob@bob.com"
        sso.name = "Bob Bobson"
        sso.username = "bob"
        sso.admin = true

        get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

        logged_on_user = Discourse.current_user_provider.new(request.env).current_user
        expect(logged_on_user).not_to eq(nil)
      end

      it 'doesn\'t allow non-staff to login' do
        sso = get_sso("/a/")
        sso.external_id = "666"
        sso.email = "bob@bob.com"
        sso.name = "Bob Bobson"
        sso.username = "bob"

        get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

        logged_on_user = Discourse.current_user_provider.new(request.env).current_user
        expect(logged_on_user).to eq(nil)
      end
    end

    it "does not create superfluous auth tokens when already logged in" do
      user = Fabricate(:user)
      sign_in(user)

      sso = get_sso("/")
      sso.email = user.email
      sso.external_id = "abc"
      sso.username = "sam"

      expect do
        get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
        logged_on_user = Discourse.current_user_provider.new(request.env).current_user
        expect(logged_on_user.id).to eq(user.id)
      end.not_to change { UserAuthToken.count }
    end

    it "will never redirect back to /session/sso path" do
      sso = get_sso("/session/sso?bla=1")
      sso.email = user.email
      sso.external_id = "abc"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("/")

      sso = get_sso("http://#{Discourse.current_hostname}/session/sso?bla=1")
      sso.email = user.email
      sso.external_id = "abc"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("/")
    end

    it "can handle invalid sso external ids due to blank" do
      sso = get_sso("/")
      sso.email = "test@test.com"
      sso.external_id = "   "
      sso.username = "sam"

      logger =
        track_log_messages do
          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
        end

      expect(logger.warnings.length).to eq(0)
      expect(logger.errors.length).to eq(0)
      expect(logger.fatals.length).to eq(0)
      expect(response.status).to eq(500)
      expect(response.body).to include(I18n.t("discourse_connect.blank_id_error"))
    end

    it "can handle invalid sso email validation errors" do
      SiteSetting.blocked_email_domains = "test.com"
      sso = get_sso("/")
      sso.email = "test@test.com"
      sso.external_id = "123"
      sso.username = "sam"

      logger =
        track_log_messages do
          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
        end

      expect(logger.warnings.length).to eq(0)
      expect(logger.errors.length).to eq(0)
      expect(logger.fatals.length).to eq(0)
      expect(response.status).to eq(500)
      expect(response.body).to include(
        I18n.t("discourse_connect.email_error", email: ERB::Util.html_escape("test@test.com")),
      )
    end

    it "can handle invalid sso external ids due to banned word" do
      sso = get_sso("/")
      sso.email = "test@test.com"
      sso.external_id = "nil"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      expect(response.status).to eq(500)
    end

    it "can take over an account" do
      user = Fabricate(:user, email: "bill@bill.com")

      sso = get_sso("/")
      sso.email = user.email
      sso.external_id = "abc"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      expect(response).to redirect_to("/")
      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user.email).to eq(user.email)
      expect(logged_on_user.single_sign_on_record.external_id).to eq("abc")
      expect(logged_on_user.single_sign_on_record.external_username).to eq("sam")

      # we are updating the email ... ensure auto group membership works

      sign_out

      SiteSetting.email_editable = false
      SiteSetting.auth_overrides_email = true

      group = Fabricate(:group, name: :bob, automatic_membership_email_domains: "jane.com")
      sso = get_sso("/")
      sso.email = "hello@jane.com"
      sso.external_id = "abc"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      logged_on_user = Discourse.current_user_provider.new(request.env).current_user

      expect(logged_on_user.email).to eq("hello@jane.com")
      expect(group.users.count).to eq(1)
    end

    def sso_for_ip_specs
      sso = get_sso("/a/")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"
      sso
    end

    it "respects IP restrictions on create" do
      ScreenedIpAddress.all.destroy_all
      get "/"
      _screened_ip =
        Fabricate(
          :screened_ip_address,
          ip_address: request.remote_ip,
          action_type: ScreenedIpAddress.actions[:block],
        )

      sso = sso_for_ip_specs
      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user).to eq(nil)
    end

    it "respects IP restrictions on login" do
      ScreenedIpAddress.all.destroy_all
      get "/"
      sso = sso_for_ip_specs
      DiscourseConnect.parse(
        sso.payload,
        secure_session: read_secure_session,
      ).lookup_or_create_user(request.remote_ip)

      sso = sso_for_ip_specs
      _screened_ip =
        Fabricate(
          :screened_ip_address,
          ip_address: request.remote_ip,
          action_type: ScreenedIpAddress.actions[:block],
        )

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user).to be_blank
    end

    it "respects email restrictions" do
      sso = get_sso("/a/")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      ScreenedEmail.block("bob@bob.com")
      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user).to eq(nil)
    end

    it "allows you to create an admin account" do
      sso = get_sso("/a/")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"
      sso.custom_fields["shop_url"] = "http://my_shop.com"
      sso.custom_fields["shop_name"] = "Sam"
      sso.admin = true

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user.admin).to eq(true)
    end

    it "does not redirect offsite" do
      sso = get_sso("#{Discourse.base_url}//site.com/xyz")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("#{Discourse.base_url}//site.com/xyz")
    end

    it "redirects to a non-relative url" do
      sso = get_sso("#{Discourse.base_url}/b/")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("/b/")
    end

    it "redirects to random url if it is allowed" do
      SiteSetting.discourse_connect_allowed_redirect_domains = "gusundtrout.com|foobar.com"

      sso = get_sso("https://gusundtrout.com")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("https://gusundtrout.com")
    end

    it "allows wildcard character to redirect to any domain" do
      SiteSetting.discourse_connect_allowed_redirect_domains = "*|foo.com"

      sso = get_sso("https://foobar.com")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("https://foobar.com")
    end

    it "does not allow wildcard character in domains" do
      SiteSetting.discourse_connect_allowed_redirect_domains = "*.foobar.com|foobar.com"

      sso = get_sso("https://sub.foobar.com")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("/")
    end

    it "redirects to root if the host of the return_path is different" do
      sso = get_sso("//eviltrout.com")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("/")
    end

    it "redirects to root if the host of the return_path is different" do
      sso = get_sso("http://eviltrout.com")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response).to redirect_to("/")
    end

    it "creates a user but ignores auto_approve_email_domains site setting when must_approve_users site setting is not enabled" do
      SiteSetting.auto_approve_email_domains = "discourse.com"

      sso = get_sso("/a/")
      sso.external_id = "666"
      sso.email = "sam@discourse.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"

      events =
        DiscourseEvent.track_events do
          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

          expect(response).to redirect_to("/a/")
        end

      expect(events.map { |event| event[:event_name] }).to include(
        :user_logged_in,
        :user_first_logged_in,
      )

      logged_on_user = Discourse.current_user_provider.new(request.env).current_user

      # ensure nothing is transient
      logged_on_user = User.find(logged_on_user.id)

      expect(logged_on_user.admin).to eq(false)
      expect(logged_on_user.email).to eq("sam@discourse.com")
      expect(logged_on_user.name).to eq("Sam Saffron")
      expect(logged_on_user.username).to eq("sam")
      expect(logged_on_user.approved).to eq(false)
      expect(logged_on_user.active).to eq(true)

      expect(logged_on_user.single_sign_on_record.external_id).to eq("666")
      expect(logged_on_user.single_sign_on_record.external_username).to eq("sam")
    end

    context "when must_approve_users site setting has been enabled" do
      before { SiteSetting.must_approve_users = true }

      it "creates a user but does not approve when user's email domain does not match a domain in auto_approve_email_domains site settings" do
        SiteSetting.auto_approve_email_domains = "discourse.com"

        sso = get_sso("/a/")
        sso.external_id = "666"
        sso.email = "sam@discourse.org"
        sso.name = "Sam Saffron"
        sso.username = "sam"

        expect do
          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

          expect(response.status).to eq(403)
          expect(response.parsed_body).to include(I18n.t("discourse_connect.account_not_approved"))
        end.to change { User.count }.by(1)

        logged_on_user = Discourse.current_user_provider.new(request.env).current_user

        expect(logged_on_user).to eq(nil)

        user = User.last

        expect(user.admin).to eq(false)
        expect(user.email).to eq("sam@discourse.org")
        expect(user.name).to eq("Sam Saffron")
        expect(user.username).to eq("sam")
        expect(user.approved).to eq(false)
        expect(user.active).to eq(true)

        expect(user.single_sign_on_record.external_id).to eq("666")
        expect(user.single_sign_on_record.external_username).to eq("sam")
      end

      it "creates and approves a user when user's email domain matches a domain in auto_approve_email_domains site settings" do
        SiteSetting.auto_approve_email_domains = "discourse.com"

        sso = get_sso("/a/")
        sso.external_id = "666"
        sso.email = "sam@discourse.com"
        sso.name = "Sam Saffron"
        sso.username = "sam"

        events =
          DiscourseEvent.track_events do
            get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

            expect(response).to redirect_to("/a/")
          end

        expect(events.map { |event| event[:event_name] }).to include(
          :user_logged_in,
          :user_first_logged_in,
        )

        logged_on_user = Discourse.current_user_provider.new(request.env).current_user

        # ensure nothing is transient
        logged_on_user = User.find(logged_on_user.id)

        expect(logged_on_user.admin).to eq(false)
        expect(logged_on_user.email).to eq("sam@discourse.com")
        expect(logged_on_user.name).to eq("Sam Saffron")
        expect(logged_on_user.username).to eq("sam")
        expect(logged_on_user.approved).to eq(true)
        expect(logged_on_user.active).to eq(true)

        expect(logged_on_user.single_sign_on_record.external_id).to eq("666")
        expect(logged_on_user.single_sign_on_record.external_username).to eq("sam")
      end
    end

    it "allows you to create an account" do
      group = Fabricate(:group, name: :bob, automatic_membership_email_domains: "bob.com")

      sso = get_sso("/a/")
      sso.external_id = "666"
      sso.email = "bob@bob.com"
      sso.name = "Sam Saffron"
      sso.username = "sam"
      sso.custom_fields["shop_url"] = "http://my_shop.com"
      sso.custom_fields["shop_name"] = "Sam"

      events =
        DiscourseEvent.track_events do
          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
        end

      expect(events.map { |event| event[:event_name] }).to include(
        :user_logged_in,
        :user_first_logged_in,
      )

      expect(response).to redirect_to("/a/")

      logged_on_user = Discourse.current_user_provider.new(request.env).current_user

      expect(group.users.where(id: logged_on_user.id).count).to eq(1)

      # ensure nothing is transient
      logged_on_user = User.find(logged_on_user.id)

      expect(logged_on_user.admin).to eq(false)
      expect(logged_on_user.email).to eq("bob@bob.com")
      expect(logged_on_user.name).to eq("Sam Saffron")
      expect(logged_on_user.username).to eq("sam")

      expect(logged_on_user.single_sign_on_record.external_id).to eq("666")
      expect(logged_on_user.single_sign_on_record.external_username).to eq("sam")
      expect(logged_on_user.active).to eq(true)
      expect(logged_on_user.custom_fields["shop_url"]).to eq("http://my_shop.com")
      expect(logged_on_user.custom_fields["shop_name"]).to eq("Sam")
      expect(logged_on_user.custom_fields["bla"]).to eq(nil)
    end

    context "when an invitation is used" do
      let(:invite) { Fabricate(:invite, email: invite_email, invited_by: Fabricate(:admin)) }
      let(:invite_email) { nil }

      def login_with_sso_and_invite(invite_key = invite.invite_key)
        write_secure_session("invite-key", invite_key)
        sso = get_sso("/")
        sso.external_id = "666"
        sso.email = "bob@bob.com"
        sso.name = "Sam Saffron"
        sso.username = "sam"

        get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      end

      it "errors if the invite key is invalid" do
        login_with_sso_and_invite("wrong")
        expect(response.status).to eq(400)
        expect(response.body).to include(I18n.t("invite.not_found", base_url: Discourse.base_url))
        expect(invite.reload.redeemed?).to eq(false)
        expect(User.find_by_email("bob@bob.com")).to eq(nil)
      end

      it "errors if the invite has expired" do
        invite.update!(expires_at: 3.days.ago)
        login_with_sso_and_invite
        expect(response.status).to eq(400)
        expect(response.body).to include(I18n.t("invite.expired", base_url: Discourse.base_url))
        expect(invite.reload.redeemed?).to eq(false)
        expect(User.find_by_email("bob@bob.com")).to eq(nil)
      end

      it "errors if the invite has been redeemed already" do
        invite.update!(max_redemptions_allowed: 1, redemption_count: 1)
        login_with_sso_and_invite
        expect(response.status).to eq(400)
        expect(response.body).to include(
          I18n.t(
            "invite.not_found_template",
            site_name: SiteSetting.title,
            base_url: Discourse.base_url,
          ),
        )
        expect(invite.reload.redeemed?).to eq(true)
        expect(User.find_by_email("bob@bob.com")).to eq(nil)
      end

      it "errors if the invite is for a specific email and that email does not match the sso email" do
        invite.update!(email: "someotheremail@dave.com")
        login_with_sso_and_invite
        expect(response.status).to eq(400)
        expect(response.body).to include(
          I18n.t("invite.not_matching_email", base_url: Discourse.base_url),
        )
        expect(invite.reload.redeemed?).to eq(false)
        expect(User.find_by_email("bob@bob.com")).to eq(nil)
      end

      it "allows you to create an account and redeems the invite successfully, clearing the invite-key session" do
        login_with_sso_and_invite

        expect(response.status).to eq(302)
        expect(response).to redirect_to("/")
        expect(invite.reload.redeemed?).to eq(true)

        user = User.find_by_email("bob@bob.com")
        expect(user.active).to eq(true)
        expect(session[:current_user_id]).to eq(user.id)
        expect(read_secure_session["invite-key"]).to eq(nil)
      end

      it "creates the user account and redeems the invite but does not approve the user if must_approve_users is enabled" do
        SiteSetting.must_approve_users = true

        login_with_sso_and_invite

        expect(response.status).to eq(403)
        expect(response.parsed_body).to include(I18n.t("discourse_connect.account_not_approved"))
        expect(invite.reload.redeemed?).to eq(true)

        user = User.find_by_email("bob@bob.com")
        expect(user.active).to eq(true)
        expect(user.approved).to eq(false)
      end

      it "redirects to the topic associated to the invite" do
        topic_invite = TopicInvite.create!(invite: invite, topic: Fabricate(:topic))
        login_with_sso_and_invite

        expect(response.status).to eq(302)
        expect(response).to redirect_to(topic_invite.topic.relative_url)
      end

      it "adds the user to the appropriate invite groups" do
        invited_group = InvitedGroup.create!(invite: invite, group: Fabricate(:group))
        login_with_sso_and_invite

        expect(invite.reload.redeemed?).to eq(true)

        user = User.find_by_email("bob@bob.com")
        expect(GroupUser.exists?(user: user, group: invited_group.group)).to eq(true)
      end
    end

    context "when sso emails are not trusted" do
      context "if you have not activated your account" do
        it "does not log you in" do
          sso = get_sso("/a/")
          sso.external_id = "666"
          sso.email = "bob@bob.com"
          sso.name = "Sam Saffron"
          sso.username = "sam"
          sso.require_activation = true

          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

          logged_on_user = Discourse.current_user_provider.new(request.env).current_user
          expect(logged_on_user).to eq(nil)
        end

        it "sends an activation email" do
          sso = get_sso("/a/")
          sso.external_id = "666"
          sso.email = "bob@bob.com"
          sso.name = "Sam Saffron"
          sso.username = "sam"
          sso.require_activation = true

          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
          expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
        end
      end

      context "if you have activated your account" do
        it "allows you to log in" do
          sso = get_sso("/hello/world")
          sso.external_id = "997"
          sso.sso_url = "http://somewhere.over.com/sso_login"
          sso.require_activation = true

          user = Fabricate(:user)
          user.create_single_sign_on_record(external_id: "997", last_payload: "")
          user.stubs(:active?).returns(true)

          get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

          logged_on_user = Discourse.current_user_provider.new(request.env).current_user
          expect(user.id).to eq(logged_on_user.id)
        end
      end
    end

    it "allows login to existing account with valid nonce" do
      sso = get_sso("/hello/world")
      sso.external_id = "997"
      sso.sso_url = "http://somewhere.over.com/sso_login"

      user = Fabricate(:user)
      user.create_single_sign_on_record(external_id: "997", last_payload: "")

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

      user.single_sign_on_record.reload
      expect(user.single_sign_on_record.last_payload).to eq(sso.unsigned_payload)

      expect(response).to redirect_to("/hello/world")
      logged_on_user = Discourse.current_user_provider.new(request.env).current_user

      expect(user.id).to eq(logged_on_user.id)

      # nonce is bad now
      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response.status).to eq(419)
    end

    it "associates the nonce with the current session" do
      sso = get_sso("/hello/world")
      sso.external_id = "997"
      sso.sso_url = "http://somewhere.over.com/sso_login"

      user = Fabricate(:user)
      user.create_single_sign_on_record(external_id: "997", last_payload: "")

      # Establish a fresh session
      cookies.to_hash.keys.each { |k| cookies.delete(k) }

      get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
      expect(response.status).to eq(419)
    end

    context "when sso provider is enabled" do
      before do
        SiteSetting.enable_discourse_connect_provider = true
        SiteSetting.discourse_connect_provider_secrets = %w[
          *|secret,forAll
          *.rainbow|wrongSecretForOverRainbow
          www.random.site|secretForRandomSite
          somewhere.over.rainbow|secretForOverRainbow
        ].join("\n")
      end

      it "doesn't break" do
        sso = get_sso("/hello/world")
        sso.external_id = "997"
        sso.sso_url = "http://somewhere.over.com/sso_login"
        sso.return_sso_url = "http://someurl.com"

        user = Fabricate(:user)
        user.create_single_sign_on_record(external_id: "997", last_payload: "")

        get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers

        user.single_sign_on_record.reload
        expect(user.single_sign_on_record.last_payload).to eq(sso.unsigned_payload)

        expect(response).to redirect_to("/hello/world")
        logged_on_user = Discourse.current_user_provider.new(request.env).current_user

        expect(user.id).to eq(logged_on_user.id)
      end
    end

    it "returns the correct error code for invalid signature" do
      sso = get_sso("/hello/world")
      sso.external_id = "997"
      sso.sso_url = "http://somewhere.over.com/sso_login"

      correct_params = Rack::Utils.parse_query(sso.payload)
      get "/session/sso_login",
          params: correct_params.merge(sig: "thisisnotthesigyouarelookingfor"),
          headers: headers
      expect(response.status).to eq(422)
      expect(response.body).not_to include(correct_params["sig"]) # Check we didn't send the real sig back to the client
      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user).to eq(nil)

      correct_params = Rack::Utils.parse_query(sso.payload)
      get "/session/sso_login",
          params: correct_params.merge(sig: "thisisasignaturewith@special!characters"),
          headers: headers
      expect(response.status).to eq(422)
      expect(response.body).not_to include(correct_params["sig"]) # Check we didn't send the real sig back to the client
      logged_on_user = Discourse.current_user_provider.new(request.env).current_user
      expect(logged_on_user).to eq(nil)
    end

    describe "local attribute override from SSO payload" do
      before do
        SiteSetting.email_editable = false
        SiteSetting.auth_overrides_email = true
        SiteSetting.auth_overrides_username = true
        SiteSetting.auth_overrides_name = true

        @user = Fabricate(:user)

        @sso = get_sso("/hello/world")
        @sso.external_id = "997"

        @reversed_username = @user.username.reverse
        @sso.username = @reversed_username
        @sso.email = "#{@reversed_username}@garbage.org"
        @reversed_name = @user.name.reverse
        @sso.name = @reversed_name

        @suggested_username = UserNameSuggester.suggest(@sso.username || @sso.name || @sso.email)
        @suggested_name = User.suggest_name(@sso.name || @sso.username || @sso.email)
        @user.create_single_sign_on_record(external_id: "997", last_payload: "")
      end

      it "stores the external attributes" do
        get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers
        @user.single_sign_on_record.reload
        expect(@user.single_sign_on_record.external_username).to eq(@sso.username)
        expect(@user.single_sign_on_record.external_email).to eq(@sso.email)
        expect(@user.single_sign_on_record.external_name).to eq(@sso.name)
      end

      it "overrides attributes" do
        get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers

        logged_on_user = Discourse.current_user_provider.new(request.env).current_user
        expect(logged_on_user.username).to eq(@suggested_username)
        expect(logged_on_user.email).to eq("#{@reversed_username}@garbage.org")
        expect(logged_on_user.name).to eq(@sso.name)
      end

      it "does not change matching attributes for an existing account" do
        @sso.username = @user.username
        @sso.name = @user.name
        @sso.email = @user.email

        get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers

        logged_on_user = Discourse.current_user_provider.new(request.env).current_user
        expect(logged_on_user.username).to eq(@user.username)
        expect(logged_on_user.name).to eq(@user.name)
        expect(logged_on_user.email).to eq(@user.email)
      end
    end

    context "when in readonly mode" do
      use_redis_snapshotting

      before { Discourse.enable_readonly_mode }

      it "disallows requests" do
        get "/session/sso_login"

        expect(response.status).to eq(503)
      end
    end
  end

  describe "#sso_provider" do
    let(:headers) { { host: Discourse.current_hostname } }
    let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
    fab!(:user) { Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) }

    before do
      stub_request(:any, %r{#{Discourse.current_hostname}/uploads}).to_return(
        status: 200,
        body: lambda { |request| file_from_fixtures("logo.png") },
      )

      SiteSetting.enable_discourse_connect_provider = true
      SiteSetting.enable_discourse_connect = false
      SiteSetting.enable_local_logins = true
      SiteSetting.discourse_connect_provider_secrets = %w[
        *|secret,forAll
        *.rainbow|wrongSecretForOverRainbow
        www.random.site|secretForRandomSite
        somewhere.over.rainbow|oldSecretForOverRainbow
        somewhere.over.rainbow|secretForOverRainbow
        somewhere.over.rainbow|newSecretForOverRainbow
      ].join("\n")

      @sso = DiscourseConnectProvider.new
      @sso.nonce = "mynonce"
      @sso.return_sso_url = "http://somewhere.over.rainbow/sso"

      @user = user
      group = Fabricate(:group)
      group.add(@user)

      @user.create_user_avatar!
      UserAvatar.import_url_for_user(logo_fixture, @user)
      UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false)
      UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true)

      @user.reload
      @user.user_avatar.reload
      @user.user_profile.reload
      EmailToken.update_all(confirmed: true)
    end

    describe "can act as an SSO provider" do
      it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do
        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        expect(response).to redirect_to("/login")

        post "/session.json",
             params: {
               login: @user.username,
               password: "myfrogs123ADMIN",
             },
             xhr: true,
             headers: headers

        location = response.cookies["sso_destination_url"]
        # javascript code will handle redirection of user to return_sso_url
        expect(location).to match(%r{^http://somewhere.over.rainbow/sso})

        payload = location.split("?")[1]
        sso2 = DiscourseConnectProvider.parse(payload)

        expect(sso2.email).to eq(@user.email)
        expect(sso2.name).to eq(@user.name)
        expect(sso2.username).to eq(@user.username)
        expect(sso2.external_id).to eq(@user.id.to_s)
        expect(sso2.admin).to eq(true)
        expect(sso2.moderator).to eq(false)
        expect(sso2.groups).to eq(@user.groups.pluck(:name).join(","))

        expect(sso2.avatar_url.blank?).to_not eq(true)
        expect(sso2.profile_background_url.blank?).to_not eq(true)
        expect(sso2.card_background_url.blank?).to_not eq(true)

        expect(sso2.avatar_url).to start_with(Discourse.base_url)
        expect(sso2.profile_background_url).to start_with(Discourse.base_url)
        expect(sso2.card_background_url).to start_with(Discourse.base_url)
        expect(sso2.confirmed_2fa).to eq(nil)
        expect(sso2.no_2fa_methods).to eq(nil)
      end

      it "correctly logs in for secondary domain secrets" do
        sign_in @user

        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("newSecretForOverRainbow"))
        expect(response.status).to eq(302)
        redirect_uri = URI.parse(response.location)
        expect(redirect_uri.host).to eq("somewhere.over.rainbow")
        redirect_query = CGI.parse(redirect_uri.query)
        expected_sig =
          DiscourseConnectBase.sign(redirect_query["sso"][0], "newSecretForOverRainbow")
        expect(redirect_query["sig"][0]).to eq(expected_sig)

        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("oldSecretForOverRainbow"))
        expect(response.status).to eq(302)
        redirect_uri = URI.parse(response.location)
        expect(redirect_uri.host).to eq("somewhere.over.rainbow")
        redirect_query = CGI.parse(redirect_uri.query)
        expected_sig =
          DiscourseConnectBase.sign(redirect_query["sso"][0], "oldSecretForOverRainbow")
        expect(redirect_query["sig"][0]).to eq(expected_sig)
      end

      it "fails to log in if secret is wrong" do
        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForRandomSite"))
        expect(response.status).to eq(422)
      end

      it "fails with a nice error message if secret is blank" do
        SiteSetting.discourse_connect_provider_secrets = ""
        sso = DiscourseConnectProvider.new
        sso.nonce = "mynonce"
        sso.return_sso_url = "http://website.without.secret.com/sso"
        get "/session/sso_provider", params: Rack::Utils.parse_query(sso.payload("aasdasdasd"))
        expect(response.status).to eq(400)
        expect(response.body).to eq(I18n.t("discourse_connect.missing_secret"))
      end

      it "returns a 422 if no return_sso_url" do
        SiteSetting.discourse_connect_provider_secrets = "abcdefghij"
        get "/session/sso_provider?sso=asdf&sig=abcdefghij"
        expect(response.status).to eq(422)
      end

      it "successfully redirects user to return_sso_url when the user is logged in" do
        sign_in(@user)

        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        location = response.header["Location"]
        expect(location).to match(%r{^http://somewhere.over.rainbow/sso})

        payload = location.split("?")[1]
        sso2 = DiscourseConnectProvider.parse(payload)

        expect(sso2.email).to eq(@user.email)
        expect(sso2.name).to eq(@user.name)
        expect(sso2.username).to eq(@user.username)
        expect(sso2.external_id).to eq(@user.id.to_s)
        expect(sso2.admin).to eq(true)
        expect(sso2.moderator).to eq(false)
        expect(sso2.groups).to eq(@user.groups.pluck(:name).join(","))

        expect(sso2.avatar_url.blank?).to_not eq(true)
        expect(sso2.profile_background_url.blank?).to_not eq(true)
        expect(sso2.card_background_url.blank?).to_not eq(true)

        expect(sso2.avatar_url).to start_with(Discourse.base_url)
        expect(sso2.profile_background_url).to start_with(Discourse.base_url)
        expect(sso2.card_background_url).to start_with(Discourse.base_url)
        expect(sso2.confirmed_2fa).to eq(nil)
        expect(sso2.no_2fa_methods).to eq(nil)
      end

      it "fails with a nice error message if `prompt` parameter has an invalid value" do
        @sso.prompt = "xyzpdq"

        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        expect(response.status).to eq(400)
        expect(response.body).to eq(
          I18n.t("discourse_connect.invalid_parameter_value", param: "prompt"),
        )
      end

      it "redirects browser to return_sso_url with auth failure when prompt=none is requested and the user is not logged in" do
        @sso.prompt = "none"

        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        location = response.header["Location"]
        expect(location).to match(%r{^http://somewhere.over.rainbow/sso})

        payload = location.split("?")[1]
        sso2 = DiscourseConnectProvider.parse(payload)

        expect(sso2.failed).to eq(true)

        expect(sso2.email).to eq(nil)
        expect(sso2.name).to eq(nil)
        expect(sso2.username).to eq(nil)
        expect(sso2.external_id).to eq(nil)
        expect(sso2.admin).to eq(nil)
        expect(sso2.moderator).to eq(nil)
        expect(sso2.groups).to eq(nil)

        expect(sso2.avatar_url).to eq(nil)
        expect(sso2.profile_background_url).to eq(nil)
        expect(sso2.card_background_url).to eq(nil)
        expect(sso2.confirmed_2fa).to eq(nil)
        expect(sso2.no_2fa_methods).to eq(nil)
      end

      it "handles non local content correctly" do
        SiteSetting.avatar_sizes = "100|49"
        setup_s3
        SiteSetting.s3_cdn_url = "http://cdn.com"

        stub_request(:any, /s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/).to_return(
          status: 200,
          body: "",
          headers: {
            referer: "fgdfds",
          },
        )

        @user.create_user_avatar!
        upload =
          Fabricate(
            :upload,
            url: "//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/something",
          )

        Fabricate(
          :optimized_image,
          sha1: SecureRandom.hex << "A" * 8,
          upload: upload,
          width: 98,
          height: 98,
          url: "//s3-upload-bucket.s3.amazonaws.com/something/else",
        )

        @user.update_columns(uploaded_avatar_id: upload.id)

        upload1 = Fabricate(:upload_s3)
        upload2 = Fabricate(:upload_s3)

        @user.user_profile.update!(
          profile_background_upload: upload1,
          card_background_upload: upload2,
        )

        @user.reload
        @user.user_avatar.reload
        @user.user_profile.reload

        sign_in(@user)

        stub_request(:get, "http://cdn.com/something/else").to_return(
          body: lambda { |request| File.new(Rails.root + "spec/fixtures/images/logo.png") },
        )

        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        location = response.header["Location"]
        # javascript code will handle redirection of user to return_sso_url
        expect(location).to match(%r{^http://somewhere.over.rainbow/sso})

        payload = location.split("?")[1]
        sso2 = DiscourseConnectProvider.parse(payload)

        expect(sso2.avatar_url.blank?).to_not eq(true)
        expect(sso2.profile_background_url.blank?).to_not eq(true)
        expect(sso2.card_background_url.blank?).to_not eq(true)

        expect(sso2.avatar_url).to start_with("#{SiteSetting.s3_cdn_url}/original")
        expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url)
        expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url)
        expect(sso2.confirmed_2fa).to eq(nil)
        expect(sso2.no_2fa_methods).to eq(nil)
      end

      it "successfully logs out and redirects user to return_sso_url when the user is logged in" do
        sign_in(@user)

        @sso.logout = true
        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        location = response.header["Location"]
        expect(location).to match(%r{^http://somewhere.over.rainbow/sso$})

        expect(response.status).to eq(302)
        expect(session[:current_user_id]).to be_blank
        expect(response.cookies["_t"]).to be_blank
      end

      it "successfully logs out and redirects user to return_sso_url when the user is not logged in" do
        @sso.logout = true
        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

        location = response.header["Location"]
        expect(location).to match(%r{^http://somewhere.over.rainbow/sso$})

        expect(response.status).to eq(302)
        expect(session[:current_user_id]).to be_blank
        expect(response.cookies["_t"]).to be_blank
      end
    end

    describe "can act as a 2FA provider" do
      fab!(:user_totp) { Fabricate(:user_second_factor_totp, user: user) }
      before { @sso.require_2fa = true }

      it "requires the user to confirm 2FA before they are redirected to the SSO return URL" do
        sign_in(user)
        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
        uri = URI(response.location)
        expect(uri.hostname).to eq(Discourse.current_hostname)
        expect(uri.path).to eq("/session/2fa")
        nonce = uri.query.match(/\Anonce=([A-Za-z0-9]{32})\Z/)[1]
        expect(nonce).to be_present

        # attempt no. 1 to bypass 2fa
        get "/session/sso_provider", params: { second_factor_nonce: nonce }
        expect(response.status).to eq(401)
        expect(response.parsed_body["error"]).to eq(
          I18n.t("second_factor_auth.challenge_not_completed"),
        )

        # attempt no. 2 to bypass 2fa
        get "/session/sso_provider",
            params: { second_factor_nonce: nonce }.merge(
              Rack::Utils.parse_query(@sso.payload("secretForOverRainbow")),
            )
        expect(response.status).to eq(401)
        expect(response.parsed_body["error"]).to eq(
          I18n.t("second_factor_auth.challenge_not_completed"),
        )

        # confirm 2fa
        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_token: ROTP::TOTP.new(user_totp.data).now,
               second_factor_method: UserSecondFactor.methods[:totp],
             }
        expect(response.status).to eq(200)
        expect(response.parsed_body["ok"]).to eq(true)
        expect(response.parsed_body["callback_method"]).to eq("GET")
        expect(response.parsed_body["callback_path"]).to eq("/session/sso_provider")
        expect(response.parsed_body["redirect_url"]).to be_blank

        get "/session/sso_provider", params: { second_factor_nonce: nonce }
        expect(response.status).to eq(200)
        expect(response.parsed_body["success"]).to eq("OK")
        redirect_url = response.parsed_body["redirect_url"]
        expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
        sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
        expect(sso.confirmed_2fa).to eq(true)
        expect(sso.no_2fa_methods).to eq(nil)
        expect(sso.username).to eq(user.username)
        expect(sso.email).to eq(user.email)
      end

      it "doesn't accept backup codes" do
        backup_codes = user.generate_backup_codes
        sign_in(user)
        get "/session/sso_provider",
            params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
        uri = URI(response.location)
        expect(uri.hostname).to eq(Discourse.current_hostname)
        expect(uri.path).to eq("/session/2fa")
        nonce = uri.query.match(/\Anonce=([A-Za-z0-9]{32})\Z/)[1]
        expect(nonce).to be_present

        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_token: backup_codes.sample,
               second_factor_method: UserSecondFactor.methods[:backup_codes],
             }
        expect(response.status).to eq(403)
        get "/session/sso_provider", params: { second_factor_nonce: nonce }
        expect(response.status).to eq(401)
        expect(response.parsed_body["error"]).to eq(
          I18n.t("second_factor_auth.challenge_not_completed"),
        )
      end

      context "when the user has no 2fa methods" do
        before do
          user_totp.destroy!
          user.reload
        end

        it "redirects the user back to the SSO return url and indicates in the payload that they do not have 2fa methods" do
          sign_in(user)
          get "/session/sso_provider",
              params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

          expect(response.status).to eq(302)
          redirect_url = response.location
          expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
          sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
          expect(sso.confirmed_2fa).to eq(nil)
          expect(sso.no_2fa_methods).to eq(true)
          expect(sso.username).to eq(user.username)
          expect(sso.email).to eq(user.email)
        end
      end

      context "when there is no logged in user" do
        it "redirects the user to login first" do
          get "/session/sso_provider",
              params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
          expect(response.status).to eq(302)
          expect(response.location).to eq("http://#{Discourse.current_hostname}/login")
        end

        it "doesn't make the user confirm 2fa twice if they've just logged in and confirmed 2fa while doing so" do
          get "/session/sso_provider",
              params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

          post "/session.json",
               params: {
                 login: user.username,
                 password: "myfrogs123ADMIN",
                 second_factor_token: ROTP::TOTP.new(user_totp.data).now,
                 second_factor_method: UserSecondFactor.methods[:totp],
               },
               xhr: true,
               headers: headers
          expect(response.status).to eq(204)
          # the frontend will take care of actually redirecting the user
          redirect_url = response.cookies["sso_destination_url"]
          expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
          sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
          expect(sso.confirmed_2fa).to eq(true)
          expect(sso.no_2fa_methods).to eq(nil)
          expect(sso.username).to eq(user.username)
          expect(sso.email).to eq(user.email)
        end

        it "doesn't indicate the user has confirmed 2fa after they've logged in if they have no 2fa methods" do
          user_totp.destroy!
          user.reload
          get "/session/sso_provider",
              params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))

          post "/session.json",
               params: {
                 login: user.username,
                 password: "myfrogs123ADMIN",
               },
               xhr: true,
               headers: headers
          redirect_url = response.cookies["sso_destination_url"]
          expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
          sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
          expect(sso.confirmed_2fa).to eq(nil)
          expect(sso.no_2fa_methods).to eq(true)
          expect(sso.username).to eq(user.username)
          expect(sso.email).to eq(user.email)
        end
      end
    end
  end

  describe "#create" do
    context "when read only mode" do
      use_redis_snapshotting

      before do
        Discourse.enable_readonly_mode
        EmailToken.confirm(email_token.token)
        EmailToken.confirm(admin_email_token.token)
      end

      it "prevents login by regular users" do
        post "/session.json", params: { login: user.username, password: "myawesomepassword" }
        expect(response.status).not_to eq(200)
      end

      it "prevents login by admins" do
        post "/session.json", params: { login: admin.username, password: "myawesomepassword" }
        expect(response.status).not_to eq(200)
      end
    end

    context "when in staff writes only mode" do
      use_redis_snapshotting

      before do
        Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY)
        EmailToken.confirm(email_token.token)
        EmailToken.confirm(admin_email_token.token)
      end

      it "allows admin login" do
        post "/session.json", params: { login: admin.username, password: "myawesomepassword" }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
      end

      it "prevents login by regular users" do
        post "/session.json", params: { login: user.username, password: "myawesomepassword" }
        expect(response.status).not_to eq(200)
      end
    end

    context "when local login is disabled" do
      before do
        SiteSetting.enable_local_logins = false

        post "/session.json", params: { login: user.username, password: "myawesomepassword" }
      end
      it_behaves_like "failed to continue local login"
    end

    context "when SSO is enabled" do
      before do
        SiteSetting.discourse_connect_url = "https://www.example.com/sso"
        SiteSetting.enable_discourse_connect = true

        post "/session.json", params: { login: user.username, password: "myawesomepassword" }
      end
      it_behaves_like "failed to continue local login"
    end

    context "when local login via email is disabled" do
      before do
        SiteSetting.enable_local_logins_via_email = false
        EmailToken.confirm(email_token.token)
      end
      it "doesnt matter, logs in correctly" do
        post "/session.json", params: { login: user.username, password: "myawesomepassword" }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
      end
    end

    context "when email is confirmed" do
      before { EmailToken.confirm(email_token.token) }

      it "raises an error when the login isn't present" do
        post "/session.json"
        expect(response.status).to eq(400)
      end

      describe "invalid password" do
        it "should return an error with an invalid password" do
          post "/session.json", params: { login: user.username, password: "sssss" }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to eq(
            I18n.t("login.incorrect_username_email_or_password"),
          )
        end

        it "should return an error with an invalid password if too long" do
          User.any_instance.expects(:confirm_password?).never
          post "/session.json",
               params: {
                 login: user.username,
                 password: ("s" * (User.max_password_length + 1)),
               }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to eq(
            I18n.t("login.incorrect_username_email_or_password"),
          )
        end
      end

      describe "suspended user" do
        it "should return an error" do
          user.suspended_till = 2.days.from_now
          user.suspended_at = Time.now
          user.save!
          StaffActionLogger.new(user).log_user_suspend(user, "<strike>banned</strike>")

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }

          expected_message =
            I18n.t(
              "login.suspended_with_reason",
              date: I18n.l(user.suspended_till, format: :date_only),
              reason: Rack::Utils.escape_html(user.suspend_reason),
            )
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to eq(expected_message)
        end

        it "when suspended forever should return an error without suspended till date" do
          user.suspended_till = 101.years.from_now
          user.suspended_at = Time.now
          user.save!
          StaffActionLogger.new(user).log_user_suspend(user, "<strike>banned</strike>")

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }

          expected_message =
            I18n.t(
              "login.suspended_with_reason_forever",
              reason: Rack::Utils.escape_html(user.suspend_reason),
            )
          expect(response.parsed_body["error"]).to eq(expected_message)
        end
      end

      describe "deactivated user" do
        it "should return an error" do
          user.active = false
          user.save!

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to eq(I18n.t("login.not_activated"))
        end
      end

      describe "success by username" do
        it "logs in correctly" do
          events =
            DiscourseEvent.track_events do
              post "/session.json", params: { login: user.username, password: "myawesomepassword" }
            end

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          expect(events.map { |event| event[:event_name] }).to contain_exactly(
            :user_logged_in,
            :user_first_logged_in,
          )

          user.reload

          expect(session[:current_user_id]).to eq(user.id)
          expect(user.user_auth_tokens.count).to eq(1)
          unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
          expect(UserAuthToken.hash_token(unhashed_token)).to eq(
            user.user_auth_tokens.first.auth_token,
          )
        end

        context "when timezone param is provided" do
          it "sets the user_option timezone for the user" do
            post "/session.json",
                 params: {
                   login: user.username,
                   password: "myawesomepassword",
                   timezone: "Australia/Melbourne",
                 }
            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).not_to be_present
            expect(user.reload.user_option.timezone).to eq("Australia/Melbourne")
          end
        end
      end

      context "when a user has security key-only 2FA login" do
        let!(:user_security_key) do
          Fabricate(
            :user_security_key,
            user: user,
            credential_id: valid_security_key_data[:credential_id],
            public_key: valid_security_key_data[:public_key],
          )
        end

        before do
          simulate_localhost_webauthn_challenge
          DiscourseWebauthn.stubs(:origin).returns("http://localhost:3000")

          # store challenge in secure session by failing login once
          post "/session.json", params: { login: user.username, password: "myawesomepassword" }
        end

        context "when the security key params are blank and a random second factor token is provided" do
          it "shows an error message and denies login" do
            post "/session.json",
                 params: {
                   login: user.username,
                   password: "myawesomepassword",
                   second_factor_token: "99999999",
                   second_factor_method: UserSecondFactor.methods[:security_key],
                 }

            expect(response.status).to eq(200)
            expect(session[:current_user_id]).to eq(nil)
            response_body = response.parsed_body
            expect(response_body["failed"]).to eq("FAILED")
            expect(response_body["error"]).to eq(
              I18n.t("webauthn.validation.malformed_public_key_credential_error"),
            )
          end
        end

        context "when the security key params are invalid" do
          it "shows an error message and denies login" do
            post "/session.json",
                 params: {
                   login: user.username,
                   password: "myawesomepassword",
                   second_factor_token: {
                     signature: "bad_sig",
                     clientData: "bad_clientData",
                     credentialId: "bad_credential_id",
                     authenticatorData: "bad_authenticator_data",
                   },
                   second_factor_method: UserSecondFactor.methods[:security_key],
                 }

            expect(response.status).to eq(200)
            expect(session[:current_user_id]).to eq(nil)
            response_body = response.parsed_body
            expect(response_body["failed"]).to eq("FAILED")
            expect(response_body["error"]).to eq(I18n.t("webauthn.validation.not_found_error"))
          end
        end

        context "when the security key params are valid" do
          it "logs the user in" do
            post "/session.json",
                 params: {
                   login: user.username,
                   password: "myawesomepassword",
                   second_factor_token: valid_security_key_auth_post_data,
                   second_factor_method: UserSecondFactor.methods[:security_key],
                 }

            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).not_to be_present
            user.reload

            expect(session[:current_user_id]).to eq(user.id)
            expect(user.user_auth_tokens.count).to eq(1)
          end
        end

        context "when the security key is disabled in the background by the user and TOTP is enabled" do
          before do
            user_security_key.destroy!
            Fabricate(:user_second_factor_totp, user: user)
          end

          it "shows an error message and denies login" do
            post "/session.json",
                 params: {
                   login: user.username,
                   password: "myawesomepassword",
                   second_factor_token: valid_security_key_auth_post_data,
                   second_factor_method: UserSecondFactor.methods[:security_key],
                 }

            expect(response.status).to eq(200)
            expect(session[:current_user_id]).to eq(nil)
            response_body = response.parsed_body
            expect(response_body["failed"]).to eq("FAILED")
            expect(response.parsed_body["error"]).to eq(
              I18n.t("login.not_enabled_second_factor_method"),
            )
          end
        end
      end

      context "when user has TOTP-only 2FA login" do
        let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
        let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }

        describe "when second factor token is missing" do
          it "should return the right response" do
            post "/session.json", params: { login: user.username, password: "myawesomepassword" }

            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).to eq(
              I18n.t("login.invalid_second_factor_method"),
            )
          end
        end

        describe "when second factor token is invalid" do
          context "when using totp method" do
            it "should return the right response" do
              post "/session.json",
                   params: {
                     login: user.username,
                     password: "myawesomepassword",
                     second_factor_token: "00000000",
                     second_factor_method: UserSecondFactor.methods[:totp],
                   }

              expect(response.status).to eq(200)
              expect(response.parsed_body["error"]).to eq(
                I18n.t("login.invalid_second_factor_code"),
              )
            end
          end

          context "when using backup code method" do
            it "should return the right response" do
              post "/session.json",
                   params: {
                     login: user.username,
                     password: "myawesomepassword",
                     second_factor_token: "00000000",
                     second_factor_method: UserSecondFactor.methods[:backup_codes],
                   }

              expect(response.status).to eq(200)
              expect(response.parsed_body["error"]).to eq(
                I18n.t("login.invalid_second_factor_code"),
              )
            end
          end
        end

        describe "when second factor token is valid" do
          context "when using totp method" do
            it "should log the user in" do
              post "/session.json",
                   params: {
                     login: user.username,
                     password: "myawesomepassword",
                     second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
                     second_factor_method: UserSecondFactor.methods[:totp],
                   }
              expect(response.status).to eq(200)
              expect(response.parsed_body["error"]).not_to be_present
              user.reload

              expect(session[:current_user_id]).to eq(user.id)
              expect(user.user_auth_tokens.count).to eq(1)

              unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
              expect(UserAuthToken.hash_token(unhashed_token)).to eq(
                user.user_auth_tokens.first.auth_token,
              )
            end
          end

          context "when using backup code method" do
            it "should log the user in" do
              post "/session.json",
                   params: {
                     login: user.username,
                     password: "myawesomepassword",
                     second_factor_token: "iAmValidBackupCode",
                     second_factor_method: UserSecondFactor.methods[:backup_codes],
                   }
              expect(response.status).to eq(200)
              expect(response.parsed_body["error"]).not_to be_present
              user.reload

              expect(session[:current_user_id]).to eq(user.id)
              expect(user.user_auth_tokens.count).to eq(1)

              unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
              expect(UserAuthToken.hash_token(unhashed_token)).to eq(
                user.user_auth_tokens.first.auth_token,
              )
            end
          end
        end
      end

      describe "with a blocked IP" do
        it "doesn't log in" do
          ScreenedIpAddress.all.destroy_all
          get "/"
          _screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip)
          post "/session.json",
               params: {
                 login: "@" + user.username,
                 password: "myawesomepassword",
               }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to be_present
          user.reload

          expect(session[:current_user_id]).to be_nil
        end
      end

      describe "strips leading @ symbol" do
        it "sets a session id" do
          post "/session.json",
               params: {
                 login: "@" + user.username,
                 password: "myawesomepassword",
               }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          user.reload

          expect(session[:current_user_id]).to eq(user.id)
        end
      end

      describe "also allow login by email" do
        it "sets a session id" do
          post "/session.json", params: { login: user.email, password: "myawesomepassword" }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          expect(session[:current_user_id]).to eq(user.id)
        end
      end

      context "when login has leading and trailing space" do
        let(:username) { " #{user.username} " }
        let(:email) { " #{user.email} " }

        it "strips spaces from the username" do
          post "/session.json", params: { login: username, password: "myawesomepassword" }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end

        it "strips spaces from the email" do
          post "/session.json", params: { login: email, password: "myawesomepassword" }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end
      end

      describe "when the site requires approval of users" do
        before { SiteSetting.must_approve_users = true }

        context "with an unapproved user" do
          before do
            user.update_columns(approved: false)
            post "/session.json", params: { login: user.email, password: "myawesomepassword" }
          end

          it "doesn't log in the user" do
            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).to be_present
            expect(session[:current_user_id]).to be_blank
          end

          it "shows the 'not approved' error message" do
            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).to eq(I18n.t("login.not_approved"))
          end
        end

        context "with an unapproved user who is an admin" do
          it "sets a session id" do
            user.admin = true
            user.save!

            post "/session.json", params: { login: user.email, password: "myawesomepassword" }
            expect(response.status).to eq(200)
            expect(response.parsed_body["error"]).not_to be_present
            expect(session[:current_user_id]).to eq(user.id)
          end
        end
      end

      context "when admins are restricted by ip address" do
        before do
          SiteSetting.use_admin_ip_allowlist = true
          ScreenedIpAddress.all.destroy_all
        end

        it "is successful for admin at the ip address" do
          get "/"
          Fabricate(
            :screened_ip_address,
            ip_address: request.remote_ip,
            action_type: ScreenedIpAddress.actions[:allow_admin],
          )

          user.admin = true
          user.save!

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          expect(session[:current_user_id]).to eq(user.id)
        end

        it "returns an error for admin not at the ip address" do
          Fabricate(
            :screened_ip_address,
            ip_address: "111.234.23.11",
            action_type: ScreenedIpAddress.actions[:allow_admin],
          )
          user.admin = true
          user.save!

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to be_present
          expect(session[:current_user_id]).not_to eq(user.id)
        end

        it "is successful for non-admin not at the ip address" do
          Fabricate(
            :screened_ip_address,
            ip_address: "111.234.23.11",
            action_type: ScreenedIpAddress.actions[:allow_admin],
          )
          user.admin = false
          user.save!

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          expect(session[:current_user_id]).to eq(user.id)
        end
      end
    end

    context "when email has not been confirmed" do
      def post_login
        post "/session.json", params: { login: user.email, password: "myawesomepassword" }
      end

      it "doesn't log in the user" do
        post_login
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).to be_present
        expect(session[:current_user_id]).to be_blank
      end

      it "shows the 'not activated' error message" do
        post_login
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).to eq(I18n.t "login.not_activated")
      end

      context "when the 'must approve users' site setting is enabled" do
        before { SiteSetting.must_approve_users = true }

        it "shows the 'not approved' error message" do
          post_login
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to eq(I18n.t "login.not_approved")
        end
      end
    end

    context "when rate limited" do
      before { RateLimiter.enable }

      use_redis_snapshotting

      it "rate limits login" do
        SiteSetting.max_logins_per_ip_per_hour = 2
        EmailToken.confirm(email_token.token)

        2.times do
          post "/session.json", params: { login: user.username, password: "myawesomepassword" }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end

        post "/session.json", params: { login: user.username, password: "myawesomepassword" }

        expect(response.status).to eq(429)
        json = response.parsed_body
        expect(json["error_type"]).to eq("rate_limit")
      end

      it "rate limits second factor attempts by IP" do
        6.times do |x|
          post "/session.json",
               params: {
                 login: "#{user.username}#{x}",
                 password: "myawesomepassword",
                 second_factor_token: "000000",
                 second_factor_method: UserSecondFactor.methods[:totp],
               }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).to be_present
        end

        post "/session.json",
             params: {
               login: user.username,
               password: "myawesomepassword",
               second_factor_token: "000000",
               second_factor_method: UserSecondFactor.methods[:totp],
             }

        expect(response.status).to eq(429)
        json = response.parsed_body
        expect(json["error_type"]).to eq("rate_limit")
      end

      it "rate limits second factor attempts by login" do
        EmailToken.confirm(email_token.token)

        6.times do |x|
          post "/session.json",
               params: {
                 login: user.username,
                 password: "myawesomepassword",
                 second_factor_token: "000000",
                 second_factor_method: UserSecondFactor.methods[:totp],
               },
               env: {
                 REMOTE_ADDR: "1.2.3.#{x}",
               }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end

        [
          user.username + " ",
          user.username.capitalize,
          user.username,
        ].each_with_index do |username, x|
          post "/session.json",
               params: {
                 login: username,
                 password: "myawesomepassword",
                 second_factor_token: "000000",
                 second_factor_method: UserSecondFactor.methods[:totp],
               },
               env: {
                 REMOTE_ADDR: "1.2.4.#{x}",
               }

          expect(response.status).to eq(429)
          json = response.parsed_body
          expect(json["error_type"]).to eq("rate_limit")
        end
      end
    end
  end

  describe "#destroy" do
    it "removes the session variable and the auth token cookies" do
      user = sign_in(Fabricate(:user))
      delete "/session/#{user.username}.json"

      expect(response.status).to eq(302)
      expect(session[:current_user_id]).to be_blank
      expect(response.cookies["_t"]).to be_blank
    end

    it "returns the redirect URL in the body for XHR requests" do
      user = sign_in(Fabricate(:user))
      delete "/session/#{user.username}.json", xhr: true

      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present
      expect(session[:current_user_id]).to be_blank
      expect(response.cookies["_t"]).to be_blank

      expect(response.parsed_body["redirect_url"]).to eq("/")
    end

    it "redirects to /login when SSO and login_required" do
      SiteSetting.discourse_connect_url = "https://example.com/sso"
      SiteSetting.enable_discourse_connect = true

      user = sign_in(Fabricate(:user))
      delete "/session/#{user.username}.json", xhr: true
      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present
      expect(response.parsed_body["redirect_url"]).to eq("/")

      SiteSetting.login_required = true
      user = sign_in(Fabricate(:user))
      delete "/session/#{user.username}.json", xhr: true
      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present
      expect(response.parsed_body["redirect_url"]).to eq("/login")
    end

    it "allows plugins to manipulate redirect URL" do
      callback = ->(data) { data[:redirect_url] = "/myredirect/#{data[:user].username}" }

      DiscourseEvent.on(:before_session_destroy, &callback)

      user = sign_in(Fabricate(:user))
      delete "/session/#{user.username}.json", xhr: true

      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present
      expect(response.parsed_body["redirect_url"]).to eq("/myredirect/#{user.username}")
    ensure
      DiscourseEvent.off(:before_session_destroy, &callback)
    end

    it "includes ip and user agent in the before_session_destroy event params" do
      callback_params = {}
      callback = ->(data) { callback_params = data }

      DiscourseEvent.on(:before_session_destroy, &callback)

      user = sign_in(Fabricate(:user))
      delete "/session/#{user.username}.json",
             xhr: true,
             headers: {
               HTTP_USER_AGENT: "AwesomeBrowser",
             }

      expect(callback_params[:user_agent]).to eq("AwesomeBrowser")
      expect(callback_params[:client_ip]).to eq("127.0.0.1")
    ensure
      DiscourseEvent.off(:before_session_destroy, &callback)
    end
  end

  describe "#one_time_password" do
    context "with missing token" do
      it "returns the right response" do
        get "/session/otp"
        expect(response.status).to eq(404)
      end
    end

    context "with invalid token" do
      it "returns the right response" do
        get "/session/otp/asd1231dasd123"

        expect(response.status).to eq(404)

        post "/session/otp/asd1231dasd123"

        expect(response.status).to eq(404)
      end

      context "when token is valid" do
        it "should display the form for GET" do
          token = SecureRandom.hex
          Discourse.redis.setex "otp_#{token}", 10.minutes, user.username

          get "/session/otp/#{token}"

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
          expect(response.body).to include(
            I18n.t("user_api_key.otp_confirmation.logging_in_as", username: user.username),
          )
          expect(Discourse.redis.get("otp_#{token}")).to eq(user.username)

          expect(session[:current_user_id]).to eq(nil)
        end

        it "should redirect on GET if already logged in" do
          sign_in(user)
          token = SecureRandom.hex
          Discourse.redis.setex "otp_#{token}", 10.minutes, user.username

          get "/session/otp/#{token}"
          expect(response.status).to eq(302)

          expect(Discourse.redis.get("otp_#{token}")).to eq(nil)
          expect(session[:current_user_id]).to eq(user.id)
        end

        it "should authenticate user and delete token" do
          user = Fabricate(:user)

          get "/session/current.json"
          expect(response.status).to eq(404)

          token = SecureRandom.hex
          Discourse.redis.setex "otp_#{token}", 10.minutes, user.username

          post "/session/otp/#{token}"

          expect(response.status).to eq(302)
          expect(response).to redirect_to("/")
          expect(Discourse.redis.get("otp_#{token}")).to eq(nil)

          get "/session/current.json"
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end
      end
    end
  end

  describe "#forgot_password" do
    context "when hide_email_address_taken is set" do
      before { SiteSetting.hide_email_address_taken = true }

      it "denies for username" do
        post "/session/forgot_password.json", params: { login: user.username }

        expect(response.status).to eq(400)
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
      end

      it "allows for username when staff" do
        sign_in(Fabricate(:admin))

        post "/session/forgot_password.json", params: { login: user.username }

        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
      end

      it "allows for email" do
        post "/session/forgot_password.json", params: { login: user.email }

        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
      end
    end

    it "raises an error without a username parameter" do
      post "/session/forgot_password.json"
      expect(response.status).to eq(400)
    end

    it "should correctly screen ips" do
      ScreenedIpAddress.create!(
        ip_address: "100.0.0.1",
        action_type: ScreenedIpAddress.actions[:block],
      )

      post "/session/forgot_password.json",
           params: {
             login: "made_up",
           },
           headers: {
             "REMOTE_ADDR" => "100.0.0.1",
           }

      expect(response.parsed_body).to eq(
        { "errors" => [I18n.t("login.reset_not_allowed_from_ip_address")] },
      )
    end

    describe "rate limiting" do
      before { RateLimiter.enable }

      use_redis_snapshotting

      it "should correctly rate limits" do
        user = Fabricate(:user)

        3.times do
          post "/session/forgot_password.json", params: { login: user.username }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end

        post "/session/forgot_password.json", params: { login: user.username }
        expect(response.status).to eq(422)

        3.times do
          post "/session/forgot_password.json",
               params: {
                 login: user.username,
               },
               headers: {
                 "REMOTE_ADDR" => "10.1.1.1",
               }

          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present
        end

        post "/session/forgot_password.json",
             params: {
               login: user.username,
             },
             headers: {
               "REMOTE_ADDR" => "100.1.1.1",
             }

        # not allowed, max 6 a day
        expect(response.status).to eq(422)
      end
    end

    context "for a non existant username" do
      it "doesn't generate a new token for a made up username" do
        expect do
          post "/session/forgot_password.json", params: { login: "made_up" }
        end.not_to change(EmailToken, :count)
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
      end
    end

    context "for an existing username" do
      fab!(:user)

      context "when local login is disabled" do
        before do
          SiteSetting.enable_local_logins = false
          post "/session/forgot_password.json", params: { login: user.username }
        end
        it_behaves_like "failed to continue local login"
      end

      context "when SSO is enabled" do
        before do
          SiteSetting.discourse_connect_url = "https://www.example.com/sso"
          SiteSetting.enable_discourse_connect = true

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }
        end
        it_behaves_like "failed to continue local login"
      end

      context "when local logins are disabled" do
        before do
          SiteSetting.enable_local_logins = false

          post "/session.json", params: { login: user.username, password: "myawesomepassword" }
        end
        it_behaves_like "failed to continue local login"
      end

      context "when local logins via email are disabled" do
        before { SiteSetting.enable_local_logins_via_email = false }
        it "does not matter, generates a new token for a made up username" do
          expect do
            post "/session/forgot_password.json", params: { login: user.username }
          end.to change(EmailToken, :count)
        end
      end

      it "generates a new token for a made up username" do
        expect do
          post "/session/forgot_password.json", params: { login: user.username }
        end.to change(EmailToken, :count)
      end

      it "enqueues an email" do
        post "/session/forgot_password.json", params: { login: user.username }
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
      end
    end

    context "when doing nothing to system username" do
      let(:system) { Discourse.system_user }

      it "generates no token for system username" do
        expect do
          post "/session/forgot_password.json", params: { login: system.username }
        end.not_to change(EmailToken, :count)
      end

      it "enqueues no email" do
        post "/session/forgot_password.json", params: { login: system.username }
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
      end
    end

    context "for a staged account" do
      let!(:staged) { Fabricate(:staged) }

      it "generates no token for staged username" do
        expect do
          post "/session/forgot_password.json", params: { login: staged.username }
        end.not_to change(EmailToken, :count)
      end

      it "enqueues no email" do
        post "/session/forgot_password.json", params: { login: staged.username }
        expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
      end
    end
  end

  describe "#current" do
    context "when not logged in" do
      it "returns 404" do
        get "/session/current.json"
        expect(response.status).to eq(404)
      end
    end

    context "when logged in" do
      let!(:user) { sign_in(Fabricate(:user)) }

      it "returns the JSON for the user" do
        get "/session/current.json"
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        json = response.parsed_body
        expect(json["current_user"]).to be_present
        expect(json["current_user"]["id"]).to eq(user.id)
      end
    end
  end

  describe "#second_factor_auth_show" do
    let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }

    it "can work for anon" do
      post "/session/2fa/test-action?username=#{user.username}", xhr: true
      expect(response.status).to eq(403)

      nonce = response.parsed_body["second_factor_challenge_nonce"]
      get "/session/2fa.json", params: { nonce: nonce }
      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present
    end

    it "throws an error if logged in to a different user" do
      sign_in user
      other_user = Fabricate(:user)
      post "/session/2fa/test-action?username=#{other_user.username}", xhr: true

      expect(response.status).to eq(400)
      expect(response.parsed_body["result"]).to eq("wrong user")
    end

    context "when logged in" do
      before { sign_in(user) }

      it "returns 404 if there is no challenge for the given nonce" do
        get "/session/2fa.json", params: { nonce: "asdasdsadsad" }
        expect(response.status).to eq(404)
        expect(response.parsed_body["error"]).to eq(
          I18n.t("second_factor_auth.challenge_not_found"),
        )
      end

      it "returns 404 if the nonce does not match the challenge nonce" do
        post "/session/2fa/test-action"
        get "/session/2fa.json", params: { nonce: "wrongnonce" }
        expect(response.status).to eq(404)
        expect(response.parsed_body["error"]).to eq(
          I18n.t("second_factor_auth.challenge_not_found"),
        )
      end

      it "returns 401 if the challenge nonce has expired" do
        post "/session/2fa/test-action", xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]
        get "/session/2fa.json", params: { nonce: nonce }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present

        freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
        get "/session/2fa.json", params: { nonce: nonce }
        expect(response.status).to eq(401)
        expect(response.parsed_body["error"]).to eq(I18n.t("second_factor_auth.challenge_expired"))
      end

      it "responds with challenge data" do
        post "/session/2fa/test-action", xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]
        get "/session/2fa.json", params: { nonce: nonce }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        challenge_data = response.parsed_body
        expect(challenge_data["totp_enabled"]).to eq(true)
        expect(challenge_data["backup_enabled"]).to eq(false)
        expect(challenge_data["security_keys_enabled"]).to eq(false)
        expect(challenge_data["allowed_methods"]).to contain_exactly(
          UserSecondFactor.methods[:totp],
          UserSecondFactor.methods[:security_key],
        )
        expect(challenge_data["description"]).to eq("this is description for test action")

        Fabricate(
          :user_security_key_with_random_credential,
          user: user,
          name: "Enabled YubiKey",
          enabled: true,
        )
        Fabricate(:user_second_factor_backup, user: user)
        post "/session/2fa/test-action", params: { allow_backup_codes: true }, xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]
        get "/session/2fa.json", params: { nonce: nonce }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        challenge_data = response.parsed_body
        expect(challenge_data["totp_enabled"]).to eq(true)
        expect(challenge_data["backup_enabled"]).to eq(true)
        expect(challenge_data["security_keys_enabled"]).to eq(true)
        expect(challenge_data["allowed_credential_ids"]).to be_present
        expect(challenge_data["challenge"]).to be_present
        expect(challenge_data["allowed_methods"]).to contain_exactly(
          UserSecondFactor.methods[:totp],
          UserSecondFactor.methods[:security_key],
          UserSecondFactor.methods[:backup_codes],
        )
      end
    end
  end

  describe "#second_factor_auth_perform" do
    let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }

    it "works as anon" do
      post "/session/2fa/test-action?username=#{user.username}", xhr: true
      nonce = response.parsed_body["second_factor_challenge_nonce"]

      token = ROTP::TOTP.new(user_second_factor.data).now
      post "/session/2fa.json",
           params: {
             nonce: nonce,
             second_factor_method: UserSecondFactor.methods[:totp],
             second_factor_token: token,
           }
      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present

      post "/session/2fa/test-action?username=#{user.username}",
           params: {
             second_factor_nonce: nonce,
           }
      expect(response.status).to eq(200)
      expect(response.parsed_body["error"]).not_to be_present
      expect(response.parsed_body["result"]).to eq("second_factor_auth_completed")
    end

    it "prevents use by different user" do
      other_user = Fabricate(:user)

      post "/session/2fa/test-action?username=#{user.username}", xhr: true
      expect(response.status).to eq(403)
    end

    context "when signed in" do
      before { sign_in(user) }

      it "returns 401 if the challenge nonce has expired" do
        post "/session/2fa/test-action", xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]

        freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
        token = ROTP::TOTP.new(user_second_factor.data).now
        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_method: UserSecondFactor.methods[:totp],
               second_factor_token: token,
             }
        expect(response.status).to eq(401)
        expect(response.parsed_body["error"]).to eq(I18n.t("second_factor_auth.challenge_expired"))
      end

      it "returns 403 if the 2FA method is not allowed" do
        Fabricate(:user_second_factor_backup, user: user)
        post "/session/2fa/test-action", xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]
        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_method: UserSecondFactor.methods[:backup_codes],
               second_factor_token: "iAmValidBackupCode",
             }
        expect(response.status).to eq(403)
      end

      it "returns 403 if the user disables the 2FA method in the middle of the 2FA process" do
        post "/session/2fa/test-action", xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]
        token = ROTP::TOTP.new(user_second_factor.data).now
        user_second_factor.destroy!
        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_method: UserSecondFactor.methods[:totp],
               second_factor_token: token,
             }
        expect(response.status).to eq(403)
      end

      it "marks the challenge as successful if the 2fa succeeds" do
        post "/session/2fa/test-action", params: { redirect_url: "/ggg" }, xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]

        token = ROTP::TOTP.new(user_second_factor.data).now
        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_method: UserSecondFactor.methods[:totp],
               second_factor_token: token,
             }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        expect(response.parsed_body["ok"]).to eq(true)
        expect(response.parsed_body["callback_method"]).to eq("POST")
        expect(response.parsed_body["callback_path"]).to eq("/session/2fa/test-action")
        expect(response.parsed_body["redirect_url"]).to eq("/ggg")

        post "/session/2fa/test-action", params: { second_factor_nonce: nonce }
        expect(response.status).to eq(200)
        expect(response.parsed_body["error"]).not_to be_present
        expect(response.parsed_body["result"]).to eq("second_factor_auth_completed")
      end

      it "does not mark the challenge as successful if the 2fa fails" do
        post "/session/2fa/test-action", params: { redirect_url: "/ggg" }, xhr: true
        nonce = response.parsed_body["second_factor_challenge_nonce"]

        token = ROTP::TOTP.new(user_second_factor.data).now.to_i
        token += token == 999_999 ? -1 : 1
        post "/session/2fa.json",
             params: {
               nonce: nonce,
               second_factor_method: UserSecondFactor.methods[:totp],
               second_factor_token: token.to_s,
             }
        expect(response.status).to eq(400)
        expect(response.parsed_body["ok"]).to eq(false)
        expect(response.parsed_body["reason"]).to eq("invalid_second_factor")
        expect(response.parsed_body["error"]).to eq(I18n.t("login.invalid_second_factor_code"))

        post "/session/2fa/test-action", params: { second_factor_nonce: nonce }
        expect(response.status).to eq(401)
      end
    end
  end

  describe "#passkey_challenge" do
    it "returns a challenge for an anonymous user" do
      get "/session/passkey/challenge.json"
      expect(response.status).to eq(200)
      expect(response.parsed_body["challenge"]).not_to eq(nil)
    end

    it "returns a challenge for an authenticated user" do
      sign_in(user)
      get "/session/passkey/challenge.json"
      expect(response.status).to eq(200)
      expect(response.parsed_body["challenge"]).not_to eq(nil)
    end

    it "reset challenge on subsequent calls" do
      get "/session/passkey/challenge.json"
      expect(response.status).to eq(200)
      challenge1 = response.parsed_body["challenge"]

      get "/session/passkey/challenge.json"
      expect(response.parsed_body["challenge"]).not_to eq(challenge1)
    end

    it "fails if local logins are not allowed" do
      SiteSetting.enable_local_logins = false

      get "/session/passkey/challenge.json"
      expect(response.status).to eq(403)
    end
  end

  describe "#passkey_login" do
    before { DiscourseWebauthn.stubs(:origin).returns("http://localhost:3000") }

    it "returns 404 if feature is not enabled" do
      SiteSetting.enable_passkeys = false

      post "/session/passkey/auth.json"
      expect(response.status).to eq(404)
    end

    context "when enable_passkeys is enabled" do
      before { SiteSetting.enable_passkeys = true }

      it "fails if public key param is missing" do
        post "/session/passkey/auth.json"
        expect(response.status).to eq(400)

        json = response.parsed_body
        expect(json["errors"][0]).to include("param is missing")
        expect(json["errors"][0]).to include("publicKeyCredential")
      end

      it "fails on malformed credentials" do
        post "/session/passkey/auth.json", params: { publicKeyCredential: "someboringstring" }
        expect(response.status).to eq(401)

        json = response.parsed_body
        expect(json["errors"][0]).to eq(
          I18n.t("webauthn.validation.malformed_public_key_credential_error"),
        )
      end

      it "fails on invalid credentials" do
        post "/session/passkey/auth.json",
             params: {
               # creds are well-formed but security key is not registered
               publicKeyCredential: {
                 signature:
                   "MEYCIQDYtbfkTGHOfizXHBHltn5KOq1eC3EM6Uq4peZ0L+3wMwIhAMgzm88qOOZ7SPYh5M6zvKMjVsUAne7n9RKdN/4Bb6z8",
                 clientData:
                   "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiWmpJMk16UmxNMlV3TkRSaFl6QmhNemczTURjMlpUaGhaR1l5T1dGaU5qSXpNamMxWmpCaU9EVmxNVFUzTURaaVpEaGpNVEUwTVdJeU1qRXkiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2x9",
                 authenticatorData: "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAA==",
                 credentialId: "humAArAAAiZZuwFE/F9Gi4BAVTsRL/FowuzQsYTPKIk=",
               },
             }

        expect(response.status).to eq(401)
        json = response.parsed_body
        expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.not_found_error"))
      end

      context "when user has a valid registered passkey" do
        let!(:passkey) do
          Fabricate(
            :user_security_key,
            credential_id: valid_passkey_data[:credential_id],
            public_key: valid_passkey_data[:public_key],
            user: user,
            factor_type: UserSecurityKey.factor_types[:first_factor],
            last_used: nil,
            name: "A key",
          )
        end

        it "fails if local logins are not allowed" do
          SiteSetting.enable_local_logins = false

          post "/session/passkey/auth.json",
               params: {
                 publicKeyCredential: valid_passkey_auth_data,
               }
          expect(response.status).to eq(403)
        end

        it "fails when the key is registered to another user" do
          simulate_localhost_passkey_challenge
          user.activate
          user.create_or_fetch_secure_identifier
          post "/session/passkey/auth.json",
               params: {
                 publicKeyCredential:
                   valid_passkey_auth_data.merge(
                     { userHandle: Base64.strict_encode64(SecureRandom.hex(20)) },
                   ),
               }
          expect(response.status).to eq(401)
          json = response.parsed_body
          expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.ownership_error"))
          expect(session[:current_user_id]).to eq(nil)
        end

        it "logs the user in" do
          simulate_localhost_passkey_challenge
          user.activate
          user.create_or_fetch_secure_identifier
          post "/session/passkey/auth.json",
               params: {
                 publicKeyCredential:
                   valid_passkey_auth_data.merge(
                     { userHandle: Base64.strict_encode64(user.secure_identifier) },
                   ),
               }
          expect(response.status).to eq(200)
          expect(response.parsed_body["error"]).not_to be_present

          expect(session[:current_user_id]).to eq(user.id)
        end
      end
    end
  end

  describe "#scopes" do
    context "when not a valid api request" do
      it "returns 404" do
        get "/session/scopes.json"
        expect(response.status).to eq(404)
      end
    end

    context "when a valid api request" do
      let(:admin) { Fabricate(:admin) }
      let(:scope) do
        ApiKeyScope.new(resource: "topics", action: "read", allowed_parameters: { topic_id: "3" })
      end
      let(:api_key) { Fabricate(:api_key, user: admin, api_key_scopes: [scope]) }

      it "returns the scopes of the api key" do
        get "/session/scopes.json",
            headers: {
              "Api-Key": api_key.key,
              "Api-Username": admin.username,
            }
        expect(response.status).to eq(200)

        json = response.parsed_body
        expect(json["scopes"].size).to eq(1)
        expect(json["scopes"].first["resource"]).to eq("topics")
        expect(json["scopes"].first["action"]).to eq("read")
        expect(json["scopes"].first["allowed_parameters"]).to eq({ topic_id: "3" }.as_json)
      end
    end
  end
end