require 'spec_helper'

describe Clearance::Session do
  before { Timecop.freeze }
  after { Timecop.return }

  let(:session) { Clearance::Session.new(env_without_remember_token) }
  let(:user) { create(:user) }

  it 'finds a user from a cookie' do
    user = create(:user)
    env = env_with_remember_token(user.remember_token)
    session = Clearance::Session.new(env)

    expect(session).to be_signed_in
    expect(session.current_user).to eq user
  end

  it 'returns nil for an unknown user' do
    env = env_with_remember_token('bogus')
    session = Clearance::Session.new(env)

    expect(session).to be_signed_out
    expect(session.current_user).to be_nil
  end

  it 'returns nil without a remember token' do
    expect(session).to be_signed_out
    expect(session.current_user).to be_nil
  end

  context "with a custom cookie name" do
    it "sets a custom cookie name in the header" do
      Clearance.configuration.cookie_name = "custom_cookie_name"

      session.sign_in user
      session.add_cookie_to_headers

      expect(remember_token_cookie(session, "custom_cookie_name")).to be_present
    end
  end

  context "with signed cookies == false" do
    it "uses cookies.signed" do
      Clearance.configuration.signed_cookie = true

      cookie_jar = {}
      expect(session).to receive(:cookies).and_return(cookie_jar)
      expect(cookie_jar).to receive(:signed).and_return(cookie_jar)

      session.sign_in user
    end
  end

  context "with signed cookies == true" do
    it "uses cookies.signed" do
      Clearance.configuration.signed_cookie = true

      cookie_jar = {}
      expect(session).to receive(:cookies).and_return(cookie_jar)
      expect(cookie_jar).to receive(:signed).and_return(cookie_jar)

      session.sign_in user
    end
  end

  context "with signed cookies == :migrate" do
    before do
      Clearance.configuration.signed_cookie = :migrate
    end

    context "signed cookie exists" do
      it "uses cookies.signed[remember_token]" do
        cookie_jar = { "remember_token" => "signed cookie" }
        expect(session).to receive(:cookies).and_return(cookie_jar)
        expect(cookie_jar).to receive(:signed).and_return(cookie_jar)

        session.sign_in user
      end
    end

    context "signed cookie does not exist yet" do
      it "uses cookies[remember_token] instead" do
        cookie_jar = { "remember_token" => "signed cookie" }
        # first call will try to get the signed cookie
        expect(session).to receive(:cookies).and_return(cookie_jar)
        # ... but signed_cookie doesn't exist
        expect(cookie_jar).to receive(:signed).and_return({})
        # then it attempts to retrieve the unsigned cookie
        expect(session).to receive(:cookies).and_return(cookie_jar)

        session.sign_in user
      end
    end
  end

  describe '#sign_in' do
    it 'sets current_user' do
      user = build(:user)

      session.sign_in user

      expect(session.current_user).to eq user
    end

    context 'with a block' do
      it 'passes the success status to the block when sign in succeeds' do
        success_status = stub_status(Clearance::SuccessStatus, true)
        success_lambda = stub_callable

        session.sign_in build(:user), &success_lambda

        expect(success_lambda).to have_been_called.with(success_status)
      end

      it 'passes the failure status to the block when sign in fails' do
        failure_status = stub_status(Clearance::FailureStatus, false)
        failure_lambda = stub_callable

        session.sign_in nil, &failure_lambda

        expect(failure_lambda).to have_been_called.with(failure_status)
      end

      def stub_status(status_class, success)
        double("status", success?: success).tap do |status|
          allow(status_class).to receive(:new).and_return(status)
        end
      end

      def stub_callable
        lambda {}.tap do |callable|
          allow(callable).to receive(:call)
        end
      end
    end

    context 'with nil argument' do
      it 'assigns current_user' do
        session.sign_in nil

        expect(session.current_user).to be_nil
      end
    end

    context 'with a sign in stack' do

      it 'runs the first guard' do
        guard = stub_sign_in_guard(succeed: true)
        user = build(:user)

        session.sign_in user

        expect(guard).to have_received(:call)
      end

      it 'will not sign in the user if the guard stack fails' do
        stub_sign_in_guard(succeed: false)
        user = build(:user)

        session.sign_in user

        expect(session.instance_variable_get("@cookies")).to be_nil
        expect(session.current_user).to be_nil
      end

      def stub_sign_in_guard(options)
        session_status = stub_status(options.fetch(:succeed))

        double("guard", call: session_status).tap do |guard|
          Clearance.configuration.sign_in_guards << stub_guard_class(guard)
        end
      end

      def stub_default_sign_in_guard
        double("default_sign_in_guard").tap do |sign_in_guard|
          allow(Clearance::DefaultSignInGuard).to receive(:new).
            with(session).
            and_return(sign_in_guard)
        end
      end

      def stub_guard_class(guard)
        double("guard_class").tap do |guard_class|
          allow(guard_class).to receive(:to_s).
            and_return(guard_class)

          allow(guard_class).to receive(:constantize).
            and_return(guard_class)

          allow(guard_class).to receive(:new).
            with(session, stub_default_sign_in_guard).
            and_return(guard)
        end
      end

      def stub_status(success)
        double("status", success?: success)
      end

      after do
        Clearance.configuration.sign_in_guards = []
      end
    end
  end

  context 'if httponly is set' do
    before do
      session.sign_in(user)
    end

    it 'sets a httponly cookie' do
      session.add_cookie_to_headers

      expect(remember_token_cookie(session)[:httponly]).to be_truthy
    end
  end

  context 'if httponly is not set' do
    before do
      Clearance.configuration.httponly = false
      session.sign_in(user)
    end

    it 'sets a standard cookie' do
      session.add_cookie_to_headers

      expect(remember_token_cookie(session)[:httponly]).to be_falsey
    end
  end

  context "if same_site is set" do
    before do
      Clearance.configuration.same_site = :lax
      session.sign_in(user)
    end

    it "sets a same-site cookie" do
      session.add_cookie_to_headers

      expect(remember_token_cookie(session)[:same_site]).to eq(:lax)
    end
  end

  context "if same_site is not set" do
    before do
      session.sign_in(user)
    end

    it "sets a standard cookie" do
      session.add_cookie_to_headers

      expect(remember_token_cookie(session)[:same_site]).to be_nil
    end
  end

  describe 'remember token cookie expiration' do
    context 'default configuration' do
      it 'is set to 1 year from now' do
        user = double("User", remember_token: "123abc")
        session = Clearance::Session.new(env_without_remember_token)
        session.sign_in user
        session.add_cookie_to_headers

        expect(remember_token_cookie(session)[:expires]).to eq(1.year.from_now)
      end
    end

    context 'configured with lambda taking one argument' do
      it 'it can use other cookies to set the value of the expires token' do
        remembered_expires = 12.hours.from_now
        expires_at = ->(cookies) do
          cookies['remember_me'] ? remembered_expires : nil
        end
        with_custom_expiration expires_at do
          user = double("User", remember_token: "123abc")
          environment = env_with_cookies(remember_me: 'true')
          session = Clearance::Session.new(environment)
          session.sign_in user
          session.add_cookie_to_headers
          expect(remember_token_cookie(session)[:expires]).to \
            eq(remembered_expires)
          expect(remember_token_cookie(session)[:value]).to \
            eq(user.remember_token)
        end
      end
    end
  end

  describe 'secure cookie option' do
    context 'when not set' do
      before do
        session.sign_in(user)
      end

      it 'sets a standard cookie' do
        session.add_cookie_to_headers

        expect(remember_token_cookie(session)[:secure]).to be_falsey
      end
    end

    context 'when set' do
      before do
        Clearance.configuration.secure_cookie = true
        session.sign_in(user)
      end

      it 'sets a secure cookie' do
        session.add_cookie_to_headers

        expect(remember_token_cookie(session)[:secure]).to be_truthy
      end
    end
  end

  describe "cookie domain option" do
    context "when set" do
      before do
        Clearance.configuration.cookie_domain = cookie_domain
        session.sign_in(user)
      end

      context "with string" do
        let(:cookie_domain) { ".example.com" }

        it "sets a standard cookie" do
          session.add_cookie_to_headers

          expect(remember_token_cookie(session)[:domain]).to eq(cookie_domain)
        end
      end

      context "with lambda" do
        let(:cookie_domain) { lambda { |_r| ".example.com" } }

        it "sets a standard cookie" do
          session.add_cookie_to_headers

          expect(remember_token_cookie(session)[:domain]).to eq(".example.com")
        end
      end
    end

    context 'when not set' do
      before { session.sign_in(user) }

      it 'sets a standard cookie' do
        session.add_cookie_to_headers

        expect(remember_token_cookie(session)[:domain]).to be_nil
      end
    end
  end

  describe 'cookie path option' do
    context 'when not set' do
      before { session.sign_in(user) }

      it 'sets a standard cookie' do
        session.add_cookie_to_headers

        expect(remember_token_cookie(session)[:domain]).to be_nil
      end
    end

    context 'when set' do
      before do
        Clearance.configuration.cookie_path = '/user'
        session.sign_in(user)
      end

      it 'sets a standard cookie' do
        session.add_cookie_to_headers

        expect(remember_token_cookie(session)[:path]).to eq("/user")
      end
    end
  end

  it 'does not set a remember token when signed out' do
    session = Clearance::Session.new(env_without_remember_token)
    session.add_cookie_to_headers
    expect(remember_token_cookie(session)).to be_nil
  end

  describe "#sign_out" do
    it "signs out a user" do
      user = create(:user)
      old_remember_token = user.remember_token
      env = env_with_remember_token(old_remember_token)
      session = Clearance::Session.new(env)
      cookie_jar = ActionDispatch::Request.new(env).cookie_jar
      expect(cookie_jar.deleted?(:remember_token)).to be false

      session.sign_out

      expect(cookie_jar.deleted?(:remember_token)).to be true
      expect(session.current_user).to be_nil
      expect(user.reload.remember_token).not_to eq old_remember_token
    end

    context "with custom cookie domain" do
      let(:domain) { ".example.com" }

      before do
        Clearance.configuration.cookie_domain = domain
      end

      it "clears cookie" do
        user = create(:user)
        env = env_with_remember_token(
          value: user.remember_token,
          domain: domain,
        )
        session = Clearance::Session.new(env)
        cookie_jar = ActionDispatch::Request.new(env).cookie_jar
        expect(cookie_jar.deleted?(:remember_token, domain: domain)).to be false

        session.sign_out

        expect(cookie_jar.deleted?(:remember_token, domain: domain)).to be true
      end
    end

    context 'with callable cookie domain' do
      it 'clears cookie' do
        domain = '.example.com'
        Clearance.configuration.cookie_domain = ->(_) { domain }
        user = create(:user)
        env = env_with_remember_token(
          value: user.remember_token,
          domain: domain
        )
        session = Clearance::Session.new(env)
        cookie_jar = ActionDispatch::Request.new(env).cookie_jar
        expect(cookie_jar.deleted?(:remember_token, domain: domain)).to be false

        session.sign_out

        expect(cookie_jar.deleted?(:remember_token, domain: domain)).to be true
      end
    end
  end

  # a bit of a hack to get the cookies that ActionDispatch sets inside session
  def remember_token_cookie(session, cookie_name = "remember_token")
    cookies = session.send(:cookies)
    # see https://stackoverflow.com/a/21315095
    set_cookies = cookies.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
      @set_cookies
    RUBY
    set_cookies[cookie_name]
  end

  def env_with_cookies(cookies)
    Rack::MockRequest.env_for '/', 'HTTP_COOKIE' => serialize_cookies(cookies)
  end

  def env_with_remember_token(token)
    env_with_cookies 'remember_token' => token
  end

  def env_without_remember_token
    env_with_cookies({})
  end

  def serialize_cookies(hash)
    header = {}

    hash.each do |key, value|
      Rack::Utils.set_cookie_header! header, key, value
    end

    header['Set-Cookie']
  end

  def have_been_called
    have_received(:call)
  end

  def with_custom_expiration(custom_duration)
    Clearance.configuration.cookie_expiration = custom_duration
    yield
  end
end
