For Techlahoma we’ve decided that we want to allow a user to connect multiple social profiles to a single account. In this installment we’ll look at writing some feature scenarios that describe the functionality that we need, and then we’ll fill out the step definitions needed to turn those scenarios into fully executable tests.
After spending a few minutes working on a couple of feature scenarios I ended up with two scenarios that cover most of the contours of this feature.
Feature: Multiple Auth Providers
As a Techlahoma admin
I want to allow users to show links to their various online personas
But I want to verify that the things they are linking to are legit
So I want to allow them to authenticate with multiple auth providers
And I'll build the URL to their profiles myself
The user should have the option to show/hide each provider
@omniauth_test
Scenario: Add Twitter
Given a user signed in with GitHub
When she visits her profile
Then she should see "Add your Twitter account"
When she adds her Twitter account
Then she should see "Would you like to make this public?"
When she makes the provider public
Then she should see "Remove Twitter"
Then she should see "Hide Twitter"
@omniauth_test
Scenario: Add GitHub
Given a user signed in with Twitter
When she visits her profile
Then she should see "Add your GitHub account"
When she adds her GitHub account
Then she should see "Would you like to make this public?"
When she does not make the provider public
Then she should see "Remove GitHub"
Then she should see "Show GitHub"
Focusing just on the "Add Twitter" scenario we can get right to work implementing step definitions. The first step is very straight forward, we just need to set up a user that is signed in through GitHub.
Given(/^a user signed in with GitHub$/) do
visit "/signin"
click_on "Sign In With GitHub"
end
This step exercises code that's already in place, so we immediately get a passing step when running cucumber.
Next up we need a step definition for "When she visits her profile". This step is also pretty easy.
When(/^she visits her profile$/) do
visit "/profile"
end
Now when we run cucumber we can see that we have some code to write to get this step to pass.
$ cucumber -r features features/users/multiple_auth.feature:9
...
When she visits her profile # features/step_definitions/users_steps.rb:19
No route matches [GET] "/profile" (ActionController::RoutingError)
features/users/multiple_auth.feature:11:in `When she visits her profile'
...
First, lets generate a controller and view to handle the /profile
path.
$ rails g controller profile index
Then edit config/routes.rb
and change this line:
get "profile/index"
to this:
get "profile" => "profile#index"
Now for the next step, "Then she should see …", we can just adapt a step definition that we already have in place. We already have a step for "The HE should see …", so we can slightly alter the regex in that step to pick up either he or she.
Then(/^[s]?he should see "(.*?)"$/) do |text|
page.should have_content(text)
end
So, now we need to start beefing up the profile page. We can get the current step to pass by just opening app/views/profile/index.html.erb
and replacing the generated content with this:
Add your Twitter account
Now, this might seem like cheating since we haven't actually put any functionality behind that (not even a link) text. That's OK. We're going to have to address that issue soon enough (in the very next step!) and for now we are continuing to make progress by working our way through our failing (or unimplemented) steps. Ideally the feature scenarios that we write will have enough detail that if we continue to write detailed features and then implement their steps we'll end up with only the application code that we need to satisfy the tested scenarios, and nothing more.
Now we're onto the step to actually add the Twitter account. The step definition itself is very simple.
When(/^she adds her Twitter account$/) do
click_on "Add your Twitter account"
end
Now when we run cucumber it will tell us that the reprieve earned from "cheating" in the last step is short lived.
$ cucumber -r features features/users/multiple_auth.feature:9
...
When she adds her Twitter account # features/step_definitions/users_steps.rb:34
Unable to find link or button "Add your Twitter account" (Capybara::ElementNotFound)
features/users/multiple_auth.feature:13:in `When she adds her Twitter account'
...
So at this point we're ready to actually add the Twitter authentication. We're relying on OmniAuth to handle most of the heavy lifting, so to get things started we just need to link the user to /auth/twitter
. So we can update the profile template to look like this:
<%= link_to "Add your Twitter account", "/auth/twitter" %>
Now cumber lets us know that we're on to the next step:
...
Then she should see "Would you like to make this public?" # features/step_definitions/users_steps.rb:24
expected to find text "Would you like to make this public?" in "Welcome Test User! Sign Out Home#index Find me in app/views/home/index.html.erb" (RSpec::Expectations::ExpectationNotMetError)
features/users/multiple_auth.feature:16:in `Then she should see "Would you like to make this public?"'
...
From the error above we can see that the home/index
template is being rendered and cucumber is not finding the text "Would you like to make this public?". We could go straight to implementing that step, but I have a hunch that something isn't quite right with the implementation for ADDING a Twitter auth onto an existing GitHub auth. After all, we haven't written any code specifically for that situation. I suspect that after the Twitter auth dance happens that a new user is being created and the old user is being logged out. So, just to test my hunch lets modify the scenario to check for a couple of things.
...
When she adds her Twitter account
Then User.count should == 1
Then Authentication.count should == 2
Then she should see "Would you like to make this public?"
...
We're just want to check to make sure that we have only one User record and 2 Authentication records. (This is kind of crude, and generally not something you'd do in a cucumber test, but it's a nice quick and dirty way to test my hunch. And it's better than having the test visit an admin portal to find out the number of users… :P )
Now running cucumber we can see that my hunch is correct.
...
Then User.count should == 1 # features/step_definitions/users_steps.rb:43
expected: 1
got: 2 (using ==) (RSpec::Expectations::ExpectationNotMetError)
features/users/multiple_auth.feature:14:in `Then User.count should == 1'
...
We're ending up with 2 Users instead of just one. So, we'll need to alter the OmniAuth callback to handle the case when the user is already logged in but wants to add an additional auth provider. The SessionsController#create
action is handling the auth provider callbacks, so we'll need to start by looking there.
def create
authentication = Authentication.from_omniauth(env["omniauth.auth"])
user = authentication.user
session[:user_id] = user.id
redirect_to root_url, notice: "Signed in!"
end
The only difference we need here is to pass in the current_user
to Authentication.from_omniauth
.
authentication = Authentication.from_omniauth(env["omniauth.auth"], current_user)
Now we need to update from_omniauth
to deal with the current user if present. We also need to remove the line from create_from_omniauth
where we create a new User
for each new Authentication
. Here's the new code for both methods.
def self.from_omniauth(auth, current_user)
auth = where(auth.slice("provider", "uid")).first || create_from_omniauth(auth)
if current_user.nil? && auth.user.nil?
auth.user = User.create(:name => auth.name)
auth.save
elsif current_user.present? && auth.user.nil?
auth.user = current_user
auth.save
end
auth
end
def self.create_from_omniauth(auth)
info = auth["user_info"] || auth["info"]
name = info["name"] rescue ""
create! do |authentication|
authentication.provider = auth["provider"]
authentication.uid = auth["uid"]
authentication.name = name
end
end
Now we're back to the stage where we should be seeing "Would you like to make this public?".
It's starting to get late, so that's going to be the end of it for tonight. In the next installment we'll finish this feature.