3
votes

In my app, I give access to a customer to HIS own deals thanks to Cancan.

It works when I try it "manually" with the browser but I fail at implementing the rspec tests. A customer can't access other customer's deals but only his own (the administrator give him access through Active Admin interface).

It's like I am not managing to make rspec understand that the customer(through FactoryGirl) I create for tests should be allowed/associated with the deals I create for the tests (again through FactoryGirl).

THE TEST: /spec/controllers/deals_controller_spec.rb

require 'spec_helper'
require "cancan/matchers"

describe DealsController do      

  context "As signed-in CUSTOMER" do 

    before do
      @customer = FactoryGirl.create(:customer) #the factory builds a basic customer i.e with 'prospect role' attributed by default
      @deal     = FactoryGirl.create(:deal, :customers => [@customer]) # in array as a deal has_many customers    
      sign_in_customer @customer
    end

    describe "the customer can read=view the page of a Deal HE OWNS " do
      it "can access the page" do
        get :deal_page, { :id => @deal.id }
        expect(current_path).to eq(deal_page_path(@deal))
        page.should have_content('Here is one of your deals, dear customer')
      end 
    end 

 end

Here is the error I get:

DealsController As signed-in CUSTOMER with access to the deal page
 Failure/Error: expect(current_path).to eq(deal_page_path(@deal))

       expected: "/deals_page/2"
            got: "/customer_interface_homepage"

       (compared using ==)

Here is the detailed test log

Deal Exists (0.8ms)  SELECT 1 AS one FROM "deals" WHERE LOWER("deals"."deal_code") = LOWER('CHA1FR001') LIMIT 1      
  SQL (2.1ms)  INSERT INTO "deals" ("admin_user_id", "client_contact_point_name", blabla") VALUES ($1, $2, blabla...) RETURNING "id"  [["admin_user_id", 1], ["client_contact_point_name", "henri Cool"], ["client_contact_point_profile_url", "http://example.com"], ....blabla...]
  (...blabla)
  Customer Exists (0.6ms)  SELECT 1 AS one FROM "customers" WHERE (LOWER("customers"."email") = LOWER('[email protected]') AND "customers"."id" != 1) LIMIT 1
  (...blabla)
Started GET "/customers/signin" for 127.0.0.1 at 2014-05-28 18:37:05 +0200
Processing by Customers::SessionsController#new as HTML
  Rendered customers/sessions/new.html.erb within layouts/lightbox (40.0ms)
  Rendered layouts/_metas.html.erb (0.4ms)
  Rendered layouts/_messages.html.erb (0.7ms)
  Rendered layouts/_footer.html.erb (1.2ms)
Completed 200 OK in 77ms (Views: 51.5ms | ActiveRecord: 0.0ms)
Started POST "/customers/signin" for 127.0.0.1 at 2014-05-28 18:37:05 +0200
Processing by Customers::SessionsController#create as HTML
  Parameters: {"utf8"=>"✓", "customer"=>{"email"=>"[email protected]", "password"=>"[FILTERED]"}, "commit"=>"Log In"}
  Customer Load (4.0ms)  SELECT "customers".* FROM "customers" WHERE "customers"."email" = '[email protected]' ORDER BY "customers"."id" ASC LIMIT 1
  SQL (1.0ms)  UPDATE "customers" SET "remember_created_at" = $1, "updated_at" = $2 WHERE "customers"."id" = 1  [["remember_created_at", 2014-05-28 16:37:05 UTC], ["updated_at", 2014-05-28 18:37:05 +0200]]
  SQL (1.2ms)  UPDATE "customers" SET "last_sign_in_at" = $1, "current_sign_in_at" = $2, "last_sign_in_ip" = $3, "current_sign_in_ip" = $4, "sign_in_count" = $5, "updated_at" = $6 WHERE "customers"."id" = 1  [["last_sign_in_at", 2014-05-28 16:37:05 UTC], ["current_sign_in_at", 2014-05-28 16:37:05 UTC], ["last_sign_in_ip", "127.0.0.1"], ["current_sign_in_ip", "127.0.0.1"], ["sign_in_count", 1], ["updated_at", 2014-05-28 18:37:05 +0200]]
**Redirected to http://www.example.com/customer_interface_homepage**
Completed 302 Found in 33ms (ActiveRecord: 6.2ms)
Started GET "/customer_interface_homepage" for 127.0.0.1 at 2014-05-28 18:37:05 +0200
Processing by ClientreportingPagesController#index as HTML
  Customer Load (0.5ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = 1 ORDER BY "customers"."id" ASC LIMIT 1
   (1.2ms)  SELECT COUNT(*) FROM "roles" INNER JOIN "customers_roles" ON "roles"."id" = "customers_roles"."role_id" WHERE "customers_roles"."customer_id" = $1 AND (((roles.name = 'prospect') AND (roles.resource_type IS NULL) AND (roles.resource_id IS NULL)))  [["customer_id", 1]]      
  Rendered layouts/_metas.html.erb (0.2ms)
   (0.8ms)  SELECT COUNT(*) FROM "roles" INNER JOIN "customers_roles" ON "roles"."id" = "customers_roles"."role_id" WHERE "customers_roles"."customer_id" = $1 AND (((roles.name = 'superadmin') AND (roles.resource_type IS NULL) AND (roles.resource_id IS NULL)))  [["customer_id", 1]]
  Rendered layouts/client_interface_partials
Completed 200 OK in 34ms (Views: 27.7ms | ActiveRecord: 2.4ms)
Processing by DealsController#deal_page as HTML
  Parameters: {"id"=>"2"}
**Completed 401 Unauthorized in 1ms**
  Rendered text template (0.1ms)
   (0.5ms)  ROLLBACK TO SAVEPOINT active_record_2
   (0.3ms)  ROLLBACK TO SAVEPOINT active_record_1
   (0.3ms)  ROLLBACK

I'm not sure it's the root cause of the issue but 2 things seem strange to me in this log:

  • why does rspec send to example.com/customer_interface_homepage (i have in my spec_helper file told rspec that i test locally: Capybara.asset_host = 'http:// localhost:3000') ?

  • why does rspec experience a "Completed 401 Unauthorized in 1ms at the end ?


Some files that might be useful to solve the issue:

/app/models/customer_ability.rb

class CustomerAbility
  include CanCan::Ability


  def initialize(customer)
    alias_action :show, :to => :read #this will have no change on the alias :read!

    customer ||= Customer.new # guest customer (not logged in)
    if customer.has_role? :superadmin 
      Log.info "Ability: customer is superadmin"
      can :manage, :all     
    else
      can :read, Deal do |deal|
        # Only customers who have been granted access in Active Admin to a deal can read 
        deal.customers.include? customer 
      end
    end
  end

end

controllers/deals_controller.rb

class DealsController < ApplicationController

 before_filter :authenticate_customer!, 
    :only => [ :deal_page ]

 def deal_page
    @deal = Deal.find(params[:id])
    authorize! :read, @deal # only allow customers with authorized access in AA; sends to customer_ability

    respond_to do |format|
      format.html 
      format.json { render json: @deal }
    end
  end

application_controller.rb

class ApplicationController < ActionController::Base

  protect_from_forgery

  # handle Cancan authorization exception
  rescue_from CanCan::AccessDenied do |exception|
    exception.default_message = t("errors.application_controller_exception_messages.only_open_to_admin")
    if current_user # if it's user redirect to main HP
      redirect_to root_path, :alert => exception.message
    else # if it's a Customer redirect him to client interface HP
      redirect_to customer_interface_homepage_path, :alert=> exception.message
    end
  end

  def current_ability #inspired by http://mikepackdev.com/blog_posts/12-managing-devise-s-current-user-current-admin-and-current-troll-with-cancan
    @current_ability ||= case
                         when current_user
                           UserAbility.new(current_user)
                         when current_customer 
                           CustomerAbility.new(current_customer)
                         end
  end

/spec/support/utilities.rb

include ApplicationHelper

def sign_in_customer(customer)
    customer.confirm!
    visit new_customer_session_path
    fill_in "Email", with: customer.email
    fill_in "Password", with: customer.password
    click_on "Log In"
    #populate cookie when not using capybara
    cookies[:authentication_token] = customer.authentication_token

end

/spec/factories/deals.rb

FactoryGirl.define do
  factory :deal do
    # id    i don't here any id
sequence(:deal_campaign_code) { |n| "CHA#{n}FR001" }        
    featured  true
    admin_user_id   1   
    end    
end    

/spec/factories/customers.rb

FactoryGirl.define do
  factory :customer do # we use prospect as by definition a visitor signing in gets 'prospect status'
    sequence(:email) { |n| "person_#{n}@example.com"}   
    password "bet(8a3#"
    password_confirmation "bet(8a3#"
    # required if the Devise Confirmable module is used
     confirmed_at Time.now
     confirmation_token nil     

    # create deals connected to the Customer
    after(:create) do |customer|
      customer.deals << FactoryGirl.create(:deal)
    end

  end

end

/app/models/customer.rb

class Customer < ActiveRecord::Base
  rolify

  # -- Relationships --------------------------------------------------------
  has_many :customer_deals,       dependent: :destroy
  has_many :deals,                through: :customer_deals

/app/models/deal.rb

class Deal < ActiveRecord::Base

  # -- Relationships --------------------------------------------------------
  belongs_to :admin_user,     :foreign_key => 'admin_user_id'
  has_many   :customer_deals, dependent: :destroy
  has_many   :customers,      through: :customer_deals      

/app/models/customer_deal.rb

class CustomerDeal < ActiveRecord::Base

  # -- Relationships --------------------------------------------------------
  belongs_to :customer,       :foreign_key => 'customer_id'
  belongs_to :deal,           :foreign_key => 'deal_id'
2
current_ability surprises me. If current_customer is set (true), is current_user not set? Also, delete the "let" lines (in your actual code and, assuming your tests still pass, in this post). You're not using the test objects that they define.Dave Schweisguth
hi, i'm pretty sure current_customer and current_user work: i have created two different devise models for Users and for Customers. As you see in utilities, here i sign in customers. I was inspired by mikepackdev.com/blog_posts/…Mathieu
you have a typo in "can :read, Deal do |Deal|" ... the proc argument should be with lowercase. It's just here or in your code also?Cristian Bica
@bcd corrected it. it was just here not on my code.Mathieu

2 Answers

1
votes

I think the session information is not being passed into the request you make in the spec. See this guide for how to use devise with controller tests.

As an alternative approach, I would recommend making this a feature spec instead of a controller spec. Note to make capybara play nice with devise follow this guide.

0
votes

To answer your log questions:

  • Not sure why it says example.com/customer_interface_homepage but you can see on the next line that it is actually sending the GET request to 127.0.0.1/customer_interface_homepage. So that part is working. Perhaps your hosts file has example.com pointing to 127.0.0.1 and the first message is resolving the IP with DNS?
  • The 401 unauthorized looks to be due to your deal controller's authorize! call.

The spec is almost certainly failing due to current_path not being updated because of the 401 return from the deals controller. I believe if you fix that issue the current_path issue will also be fixed.

I have had problems in the past with FactoryGirl not actually writing models to the database on creation. This will cause anything relying on associations (which authorize! looks like it probably relies on due to CustomerAbility) to fail.

My suggestions moving forward are to debug authorize! (likely using log/puts statements) and figuring out what @deal actually looks like at that point as well as what deal.customers looks like. If you post the code to authorize! we may be able to help more.