expect_any_instance_of with an object argument - ruby

I am testing a class's initialization block as below
class A
attr_accessor :client
def initialize(options, configuration)
self.client = B.new(options)
config = C.new(
url: configuration[:url],
headers: configuration[:headers],
username: configuration[:username],
password: configuration[:password]
)
client.configure(config)
end
end
class C
def initialize(options)
# does something with options hash
end
end
class B
def initialize(options)
# does something with options hash
end
def configure(config)
# some configuration with config object
end
end
My test case is as follows:
let(:options) {
{
force_basic_auth: true
}
}
let(:configuration) {
{
url: 'https://localhost:3000',
headers: { awesome: true },
username: 'test',
password: 'pass'
}
}
let(:api_config) {
C.new(configuration)
}
it 'configures object with passed params' do
expect_any_instance_of(B).to receive(:configure)
.with(api_config)
A.new(
options,
configuration
)
end
This fails my test case because the object that is created in the initialization block has a different object_id than the object_id of api_config which I am using in the expectations.
-[#<C:0x00000002b51128 #url="https://localhost:3000", #headers={:awesome=>true}, #username="test", #password="pass">]
+[#<C:0x00000002a1b628 #url="https://localhost:3000", #headers={:awesome=>true}, #username="test", #password="pass">]
Seeing that failure I was thinking whether it's a best practice to pass such objects directly in the initialization block. I mean I can fix it by directly passing the object in the initialization block.
There are many functions which are initializing the A class with a hash option being passed because of which I am doing it in the current way.
Is there a way to expect the contents of the object passed in rspec instead of verifying the objects are same ? Is passing the object directly in the initialization a more better approach ?

You can define arbitrary expectation handling to check the value of the parameter checked (see here):
it 'configures object with passed params' do
expect_any_instance_of(B).to receive(:configure) do |config|
expect(config).to be_a(C)
expect(config.url).to eq(configuration[:url])
expect(config.headers).to eq(configuration[:headers])
# ...
end
A.new(
options,
configuration
)
end

You want the configuration hash (rather than the object) under B.configure(config), so your class has to change slightly to accommodate.
Class file
class A
attr_accessor :client
def initialize(options, configuration)
self.client = B.new(options)
config = C.new(
url: configuration[:url],
headers: configuration[:headers],
username: configuration[:username],
password: configuration[:password]
)
client.configure(config.options)
end
end
class C
attr_reader :options
def initialize(options)
#options = options
end
end
class B
def initialize(options)
# does something with options hash
end
def configure(config)
# some configuration with config object
end
end
Here's what your RSpec code would look like.
describe do
let(:options) do
{
force_basic_auth: true
}
end
let(:configuration) do
{
url: 'https://localhost:3000',
headers: { awesome: true },
username: 'test',
password: 'pass'
}
end
let(:my_a_object) { A.new(options, configuration) }
let(:my_b_object) { B.new(options) }
it 'configures object with passed params' do
allow(B).to receive(:new).with(options).and_return(my_b_object)
expect(my_b_object).to receive(:configure).with(configuration)
my_a_object
end
end

Related

RSpec double/mock instance variable from initializer

I've got a class where in initializer I need to call instance variable from parsed params:
class PrintResults
include SortResults
attr_accessor :views_hash
def initialize(parser)
#parser = parser
#views_hash = parser.page_views
end
I want to test attributes accessors, I tried something below:
RSpec.describe PrintResults do
subject { described_class.new(views_hash) }
describe 'attributes accessors' do
let(:accessors) { double(page_views: { '/that_70s_show' => ['111.111.111.111'] }) }
it 'should have views hash' do
subject.views_hash = accessors
expect(subject.views_hash).to eq(['111.111.111.111'])
end
end
but I'm getting an error:
1) PrintResults attributes accessors should have views hash
Failure/Error: expect(subject.views_hash).to eq(['111.111.111.111'])
expected: ["111.111.111.111"]
got: #<Double (anonymous)>
(compared using ==)
Diff:
## -1 +1 ##
-["111.111.111.111"]
+#<Double (anonymous)>
You assign your test double directly to the attribute that is returned instead of using the initialize method.
Instead of
subject { described_class.new(views_hash) }
describe 'attributes accessors' do
let(:accessors) { double(page_views: { '/that_70s_show' => ['111.111.111.111'] }) }
it 'should have views hash' do
subject.views_hash = accessors
expect(subject.views_hash).to eq(['111.111.111.111'])
end
end
use
subject { described_class.new(parser) }
describe 'attributes accessors' do
let(:parser) { double(page_views: { '/that_70s_show' => ['111.111.111.111'] }) }
it 'should have views hash' do
expect(subject.views_hash).to eq('/that_70s_show' => ['111.111.111.111'])
end
end

Stubbing out an instance of a class to test a callback given as an attribute

I'm attempting to test a line of code in a proc see (proc { |message| flash[:notice] << message } in the code snippet below) using Rspec 3.9 but I can't seem to stub out the instance to do what I want.
Given the following controller and test, how can I stub out CreateAccount and run the on_success attribute given in the controller?
Here is the controller file
class AccountsController < ApplicationController
def create
CreateAccount.new(
on_success: proc { |message| flash[:notice] << message }
).process
redirect_to action: :index
end
end
Here is the Rspec test file
describe AccountsController, type: :controller do
describe 'POST #create' do
subject(:create_action) { post :create, id: 1 }
let(:success_message) { 'Success!' }
context 'when created account successfully' do
it { is_expected.to redirect_to action: :index }
it do
create_action
expect(flash[:notice]).to include success_message
end
end
end
end
The reason I want to do this is to separate concerns from the controller to the CreateAccount object. It shouldn't matter, but here is the CreateAccount object so far.
class CreateAccount
def initialize on_success: proc { |_| }
#on_success = on_success
end
def call
# Do some meaningful work
success_message = 'Meaningful message'
#on_success.call(success_message)
end
end
I've managed to find 1 solution that works but I would like to know if there's a cleaner way
Note: The controller and CreateAction classes remain the same.
describe AccountsController, type: :controller do
describe 'POST #create' do
subject(:create_action) { post :create, id: 1 }
context 'when created account successfully' do
let(:dummy_class) do
Class.new(CreateAction) do
def call
#on_success.call(self.class.message)
end
def self.message
'Success!'
end
end
end
before { stub_constant 'CreateAction', dummy_class }
it { is_expected.to redirect_to action: :index }
it do
create_action
expect(flash[:notice]).to include dummy_class.message
end
end
end
end

Copy text in html.slim using clipboard.js

I have a two-factor verification page, a secret key(Ciphertext) is displayed on it and I already have clipboard.js installed in my application.
I wonder how it is possible to create a button to copy that secret key?
= simple_form_for #google_auth, as: 'google_auth', url: verify_google_auth_path do |f|
h4 = t('.step-1')
p
span = t('.download-app')
span == t('.guide-link')
h4 = t('.step-2')
p: span = t('.scan-qr-code')
= f.input :uri do
= qr_tag(#google_auth.uri)
= f.input :otp_secret do
.input-group
= f.input_field :otp_secret, class: 'upcase', readonly: true
span.input-group-btn
a.btn.btn-default href='#{verify_google_auth_path(:app, refresh: true)}'
i.fa.fa-refresh
h4 = t('.step-3')
p: span = t('.enter-passcode')
= f.input :otp
hr.split
= f.button :wrapped, t('.submit'), cancel: settings_path
= content_for :guide do
ul.list-unstyled
li: a target='_blank' href='https://apps.apple.com/br/app/authy/id494168017'
i.fa.fa-apple
span = t('.ios')
li: a target='_blank' href='https://play.google.com/store/apps/details?id=com.authy.authy'
i.fa.fa-android
span = t('.android')
I tried to do like this, but it didn't work:
a.btn.btn-default data-clipboard-action='copy' data-clipboard-target=':otp_secret'
i.fa.fa-clipboard
In the example above it is copying only the pure otp_secret text.
spec\models\two_factor\app_spec.rb:
require 'spec_helper'
describe TwoFactor::App do
let(:member) { create :member }
let(:app) { member.app_two_factor }
describe "generate code" do
subject { app }
its(:otp_secret) { should_not be_blank }
end
describe '#refresh' do
context 'inactivated' do
it {
orig_otp_secret = app.otp_secret.dup
app.refresh!
expect(app.otp_secret).not_to eq(orig_otp_secret)
}
end
context 'activated' do
subject { create :two_factor_app, activated: true }
it {
orig_otp_secret = subject.otp_secret.dup
subject.refresh!
expect(subject.otp_secret).to eq(orig_otp_secret)
}
end
end
describe 'uniq validate' do
let(:member) { create :member }
it "reject duplicate creation" do
duplicate = TwoFactor.new app.attributes
expect(duplicate).not_to be_valid
end
end
describe 'self.fetch_by_type' do
it "return nil for wrong type" do
expect(TwoFactor.by_type(:foobar)).to be_nil
end
it "create new one by type" do
expect {
expect(app).not_to be_nil
}.to change(TwoFactor::App, :count).by(1)
end
it "retrieve exist one instead of creating" do
two_factor = member.app_two_factor
expect(member.app_two_factor).to eq(two_factor)
end
end
describe '#active!' do
subject { member.app_two_factor }
before { subject.active! }
its(:activated?) { should be_true }
end
describe '#deactive!' do
subject { create :two_factor_app, activated: true }
before { subject.deactive! }
its(:activated?) { should_not be_true }
end
describe '.activated' do
before { create :member, :app_two_factor_activated }
it "should has activated" do
expect(TwoFactor.activated?).to be_true
end
end
describe 'send_notification_mail' do
let(:mail) { ActionMailer::Base.deliveries.last }
describe "activated" do
before { app.active! }
it { expect(mail.subject).to match('Google authenticator activated') }
end
describe "deactived" do
let(:member) { create :member, :app_two_factor_activated }
before { app.deactive! }
it { expect(mail.subject).to match('Google authenticator deactivated') }
end
end
end
app.rb:
class TwoFactor::App < ::TwoFactor
def verify?
return false if otp_secret.blank?
rotp = ROTP::TOTP.new(otp_secret)
if rotp.verify(otp)
touch(:last_verify_at)
true
else
errors.add :otp, :invalid
false
end
end
def uri
totp = ROTP::TOTP.new(otp_secret)
totp.provisioning_uri(member.email) + "&issuer=#{ENV['URL_HOST']}"
end
def now
ROTP::TOTP.new(otp_secret).now
end
def refresh!
return if activated?
super
end
private
def gen_code
self.otp_secret = ROTP::Base32.random_base32
self.refreshed_at = Time.new
end
def send_notification
return if not self.activated_changed?
if self.activated
MemberMailer.google_auth_activated(member.id).deliver
else
MemberMailer.google_auth_deactivated(member.id).deliver
end
end
end
EDIT:
app\models\two_factor.rb:
class TwoFactor < ActiveRecord::Base
belongs_to :member
before_validation :gen_code, on: :create
after_update :send_notification
validates_presence_of :member, :otp_secret, :refreshed_at
attr_accessor :otp
SUBCLASS = ['app', 'sms', 'email', 'wechat']
validates_uniqueness_of :type, scope: :member_id
scope :activated, -> { where(activated: true) }
scope :require_signin, -> { where(require_signin: 1) }
class << self
def by_type(type)
return if not SUBCLASS.include?(type.to_s)
klass = "two_factor/#{type}".camelize.constantize
klass.find_or_create_by(type: klass.name)
end
def activated?
activated.any?
end
def require_signin?
require_signin.any?
end
end
def verify?
msg = "#{self.class.name}#verify? is not implemented."
raise NotImplementedError.new(msg)
end
def expired?
Time.now >= 30.minutes.since(refreshed_at)
end
def refresh!
gen_code
save
end
def active!
update activated: true, last_verify_at: Time.now
end
def set_require_signin
update require_signin: 1
end
def reset_require_signin
update require_signin: nil
end
def deactive!
update activated: false, require_signin: nil
end
private
def gen_code
msg = "#{self.class.name}#gen_code is not implemented."
raise NotImplementedError.new(msg)
end
def send_notification
msg = "#{self.class.name}#send_notification is not implemented."
raise NotImplementedError.new(msg)
end
end
What it seems you're trying to do is just to copy the value of an input field(which has been populated by other code you have) to the system clipboard. You need to use javascript to do this, if you have jquery this should work.
For your slim you need an id to target it
a.btn.btn-default id= "copy"
i.fa.fa-clipboard
Try to add an id to the input element you want to copy from
= f.input_field :otp_secret, class: 'upcase', id: "secret", readonly: true
Now try to change this and see if works.
a.btn.btn-default data-clipboard-action='copy' data-clipboard-target='secret'
i.fa.fa-clipboard
Also somewhere in your javascript you'll need to target the clip event with something like this:
new ClipboardJS('#secret');
See example here https://jsfiddle.net/ec3ywrzd/
Then you'll need this javascript to load in your html. But you'll need to be able to target the cipher field, in this example I'm using id="secret". I'm not sure if the OTP code you have generates it's own ID or now, so you may need to inspect your dom to figure out how to target it to add an ID. You may try adding an ID here:
= f.input_field :otp_secret, class: 'upcase', id: "secret", readonly: true
Otherwise you'll have to use other query selectors to target it.
But you may not need clipboardjs at all.
Here's a basic example on jsfiddle to test it you can just add any string to the input field. You'll need to add this to a JS file which will be loaded by your view layout, i.e. application.js
$(document).ready(function() {
$('#copy').click(function(){
$('#secret').select();
document.execCommand('copy');
alert("copied!");
})
})
You may also see answers to this question
I managed to solve based on suggestions from our friend #lacostenycoder.
There was only a need to change even in the show.html.slim file, looking like this:
= simple_form_for #google_auth, as: 'google_auth', url: verify_google_auth_path do |f|
h4 = t('.step-1')
p
span = t('.download-app')
span == t('.guide-link')
h4 = t('.step-2')
p: span = t('.scan-qr-code')
= f.input :uri do
= qr_tag(#google_auth.uri)
= f.input :otp_secret do
.input-group
.form-control.form-control-static = #google_auth.otp_secret
.input-group
a.btn.btn-default href="javascript:void(0)" data-clipboard-text = #google_auth.otp_secret
i.fa.fa-clipboard
a.btn.btn-default href='#{verify_google_auth_path(:app, refresh: true)}'
i.fa.fa-refresh
h4 = t('.step-3')
p: span = t('.enter-passcode')
= f.input :otp
hr.split
= f.button :wrapped, t('.submit'), cancel: settings_path
= content_for :guide do
ul.list-unstyled
li: a target='_blank' href='https://apps.apple.com/br/app/authy/id494168017'
i.fa.fa-apple
span = t('.ios')
li: a target='_blank' href='https://play.google.com/store/apps/details?id=com.authy.authy'
i.fa.fa-android
span = t('.android')

Rails 5 API custom authentication: 'skip_before_action' on GET routes

I've used this series as a starting point for a Rails backend for a work portfolio website. Adapting it has been mostly straightforward, and it's doing what I want it to. The one big problem is that the 'index' and 'show' (read actions) should be available without authentication, while 'create', 'update', and 'delete' (write actions) should require a valid JWT.
Following the approach used to exclude the signup and login routes from authentication, I've tried
skip_before_action :authorize_request, only: [:index, :show]
in the appropriate controller. This will however crash the application, with
NoMethodError (undefined method `works' for nil:NilClass):
app/controllers/works_controller.rb:10:in `index'
While the problem seems apparent - if skipping the authentication action the class doesn't get instantiated - the fix isn't, to me at least. Could anyone please help?
The code for the project is here.
Application controller
class ApplicationController < ActionController::API
include Response
include ExceptionHandler
# called before every action on controllers
before_action :authorize_request
attr_reader :current_user
private
# Check for valid request token and return user
def authorize_request
#current_user = (AuthorizeApiRequest.new(request.headers).call)[:user]
end
end
'Works' controller
class WorksController < ApplicationController
#skip_before_action :authorize_request, only: [:index, :show]
before_action :set_work, only: [:show, :update, :destroy]
# GET /works
def index
#works = current_user.works
json_response(#works)
end
# POST /works
def create
#work = current_user.works.create!(work_params)
json_response(#work, :created)
end
# GET /works/:id
def show
json_response(#work)
end
# PUT /works/:id
def update
#work.update(work_params)
head :no_content
end
# DELETE /works/:id
def destroy
#work.destroy
head :no_content
end
private
def work_params
# whitelist params
params.permit(:title, :nature, :role, :client, :timeframe, :description, :images, :url, :blog_post)
end
def set_work
#work = Work.find(params[:id])
end
end
'Users' controller
class UsersController < ApplicationController
skip_before_action :authorize_request, only: :create
def create
user = User.create!(user_params)
auth_token = AuthenticateUser.new(user.username, user.password).call
response = { message: Message.account_created, access_token: auth_token }
json_response(response, :created)
end
def show
json_response(username: current_user.username)
end
private
def user_params
params.permit(
:username,
:password,
:password_confirmation
)
end
end
'Authentication' controller
class AuthenticationController < ApplicationController
skip_before_action :authorize_request, only: :authenticate
# return auth token once user is authenticated
def authenticate
auth_token =
AuthenticateUser.new(auth_params[:username], auth_params[:password]).call
json_response(access_token: auth_token)
end
private
def auth_params
params.permit(:username, :password)
end
end
'AuthenticateUser' helper
class AuthenticateUser
def initialize(username, password)
#username = username
#password = password
end
# Service entry point
def call
JsonWebToken.encode(user_id: user.id) if user
end
private
attr_reader :username, :password
# verify user credentials
def user
user = User.find_by(username: username)
return user if user && user.authenticate(password)
# raise Authentication error if credentials are invalid
raise(ExceptionHandler::AuthenticationError, Message.invalid_credentials)
end
end
'AuthorizeApiRequest' helper
class AuthorizeApiRequest
def initialize(headers = {})
#headers = headers
end
# Service entry point - return valid user object
def call
{
user: user
}
end
private
attr_reader :headers
def user
# check if user is in the database
# memoize user object
#user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token
# handle user not found
rescue ActiveRecord::RecordNotFound => e
# raise custom error
raise(
ExceptionHandler::InvalidToken,
("#{Message.invalid_token} #{e.message}")
)
end
# decode authentication token
def decoded_auth_token
#decoded_auth_token ||= JsonWebToken.decode(http_auth_header)
end
# check for token in `Authorization` header
def http_auth_header
if headers['Authorization'].present?
return headers['Authorization'].split(' ').last
end
raise(ExceptionHandler::MissingToken, Message.missing_token)
end
end
'ExceptionHandler' helper
module ExceptionHandler
extend ActiveSupport::Concern
# Define custom error subclasses - rescue catches `StandardErrors`
class AuthenticationError < StandardError; end
class MissingToken < StandardError; end
class InvalidToken < StandardError; end
included do
# Define custom handlers
rescue_from ActiveRecord::RecordInvalid, with: :four_twenty_two
rescue_from ExceptionHandler::AuthenticationError, with: :unauthorized_request
rescue_from ExceptionHandler::MissingToken, with: :four_twenty_two
rescue_from ExceptionHandler::InvalidToken, with: :four_twenty_two
rescue_from ActiveRecord::RecordNotFound do |e|
json_response({ message: e.message }, :not_found)
end
end
private
# JSON response with message; Status code 422 - unprocessable entity
def four_twenty_two(e)
json_response({ message: e.message }, :unprocessable_entity)
end
# JSON response with message; Status code 401 - Unauthorized
def unauthorized_request(e)
json_response({ message: e.message }, :unauthorized)
end
end
The error message states:
NoMethodError (undefined method `works' for nil:NilClass):
app/controllers/works_controller.rb:10:in `index'
Or to translate that, on line 10 of the works_controller.rb file, we're calling a method called works on nil, which is throwing an error.
Assuming line 10 of the works_controller is
#works = current_user.works
Then the error message is telling us that we're calling works on nil, i.e. we have no current_user.
Either where you assign this code is not working properly, or you're accessing this part of the code without signing in and haven't coded around that. Either way, the current_user variable is returning nil and shouldn't be.

How to properly mock objects in RSpec?

I have a simple class, which generates a download URL to a file stored on S3 and I need to write a unit test to test this class. So far I've had no luck.
class S3DownloadUrlGenerator
def initialize(filename)
#filename = filename
end
def presigned_url
signer = Aws::S3::Presigner.new(client: s3)
signer.presigned_url(
:get_object,
bucket: "my-bucket",
key: filename,
response_content_disposition: "attachment",
)
end
private
def s3
#s3 ||= Aws::S3::Client.new(
region: "my-region,
http_open_timeout: 5,
http_read_timeout: 25,
)
end
attr_reader :filename
end
I want to test if calling #presigned_url on an instance of S3DownloadUrlGenerator returns a URL.
This is my test:
describe S3DownloadUrlGenerator do
before do
allow(Aws::S3::Client).to receive(:new) { s3_client }
end
let(:s3_client) { spy("s3 client") }
let(:presigner) { spy("s3 presigner") }
it "generates download URL for a file" do
expect(Aws::S3::Presigner).to receive(:new).with(client: s3_client).and_return(presigner)
expect(presigner).to receive(:presigned_url).with(
:get_object,
bucket: "my-test-bucket",
key: "test_file.txt",
response_content_disposition: "attachment",
).and_return("https://www.example.com")
expect(described_class.new("Test_file.txt").presigned_url).to eq("https://www.example.com")
end
end
but I get an error:
Failure/Error: expect(described_class.new("Test_file.txt").presigned_url).to eq("https://www.example.com")
expected: "https://www.example.com"
got: #<Double "s3 presigner">
(compared using ==)
I am bit new to this and I would like to learn how to properly test such cases. Thank you very much for the help.
bucket and key parameters differ in actual calling and mocking.
Use below code it works:
describe S3DownloadUrlGenerator do
before do
allow(Aws::S3::Client).to receive(:new) { s3_client }
end
let(:s3_client) { spy("s3 client") }
let(:presigner) { spy("s3 presigner") }
it "generates download URL for a file" do
expect(Aws::S3::Presigner).to receive(:new).with(client: s3_client).and_return(presigner)
expect(presigner).to receive(:presigned_url).with(
:get_object,
bucket: "my-bucket",
key: "Test_file.txt",
response_content_disposition: "attachment",
).and_return("https://www.example.com")
expect(described_class.new("Test_file.txt").presigned_url).to eq("https://www.example.com")
end
end

Resources