Tuesday, May 28, 2013

Fixing a database problem on a Heroku app

Recently, I was working on an upgrade to a Heroku app and found to my surprise that my schema_migrations table was wrong in production (horror, actually... I hate this kind of problem because of the possibility of data corruption). The database tables were correct, but the schema was behind by 11 migrations. Suffice it to say I have no idea how that happened, but it must have occurred when I was upgrading from a staging instance to production earlier this month [Note to self: figure out a reliable methodology for doing this].

There are a number of posts that recommend directly updating the Heroku database; this one, for example. I started to take this approach and then realized that there was a safer way to make this change. Heroku has a set of database commands that allow you to copy your production database down to your location machine or copy it back to production. This is better, IMO, than mucking with production data live... even with a backup. Working on your local machine, you can run your tests and generally use all available tools to make sure the modifications work before you upload it back to production.

The steps are dead simple:
  1. Find the id of your production database:
    heroku config --app [name]
    the id you want is usually something like "HEROKU_POSTGRESQL_PINK_URL"where "PINK" will probably be some other color.
  2. Make a backup of your production database using the database id:
    heroku pgbackups:capture --app [name] [database id]
  3. "Pull" the production database down to your local machine (Heroku drops it into your development database):
    heroku db:pull --app [name]
  4. Make your changes
  5. Run your tests
  6. "Push" the modified data back to production:
    heroku db:push --app [name]
  7. Open your browser and run a smoke test on your updated database.
Mission accomplished.

Sunday, May 26, 2013

RSpec / Devise / Capybara & feature specs with HTTP Authentication

I'm working on a couple of sites which use HTTP Authentication and - after much googling and failed attempts - developed a way to deal with that with a set of spec helpers. It all started with this gist from Matt Connolly - thanks Matt!

For the record, I'm developing with the following:
ruby 1.9.3p327
rails 3.2.12
devise  2.2.3
capybara 1.1.4
rspec  2.13.0

What I wanted was to be able to say:
def before do
and have it Just Work.

In preparation for Capybara 2.0, I'm putting all my integration tests in the spec/features directory and that created some of the confusion. Code in the spec/integration directory has access to the controller, whereas code in the spec/features directory does not. This means that both HTTP Authentication and Devise login must be handled differently.  To resolve this, I started with Matt's approach and modified it so that (so far), it works in any of my tests.

First, I modified Matt's module to add login processing and separate out the HTTP Authentication so that it could be used for controller as well as feature specs. Here's the code:

## spec/support/auth_helper.rb
module HTTPHelper
  def http_config(test_type)
    @test_type = test_type

  def http_login(user,pw)
    puts "@test_type: #{@test_type}"
    if @test_type == :controller then
      request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user,pw)
    elsif @test_type == :feature then
      if page.driver.respond_to?(:basic_auth)
        puts 'Responds to basic_auth'
        page.driver.basic_auth(user, pw)
      elsif page.driver.respond_to?(:basic_authorize)
        puts 'Responds to basic_authorize'
        page.driver.basic_authorize(user, pw)
      elsif page.driver.respond_to?(:browser) && page.driver.browser.respond_to?(:basic_authorize)
        puts 'Responds to browser_basic_authorize'
        page.driver.browser.basic_authorize(user, pw)
      elsif page.driver.respond_to?(:browser) && page.driver.respond_to?(:header)
        encoded_login = ["#{user}:#{pw}"].pack("m*")
        page.driver.header 'Authorization', "Basic #{encoded_login}"
        puts "page.driver.methods: #{page.driver.methods.sort}"
        if page.driver.respond_to?(:browser) then
          puts "page.driver.browser methods: #{page.driver.browser.methods.sort}"
        raise "I don't know how to log in!"
      raise "I don't know what kind of test this is!"

module AuthHelper
  include HTTPHelper
### For controller specs

  def login_admin
  def login_user(user_name=nil)
    http_config :controller
    http_login('HTTPname', 'HTTPpassword')
    if user_name.nil? then
      @current_user = FactoryGirl.create :user
      sign_in @current_user
      raise NotImplementedError
      @current_user = FactoryGirl.create :user, :name => :user_name
      user = User.where(:name => user_name.to_s).first if user.is_a?(Symbol)
      sign_in user.id
  def current_login

module AuthRequestHelper
  include HTTPHelper
### For request, feature & view specs  
# pass the @env along with your request, eg:
# GET '/labels', {}, @env

  def login_user(user_name=nil)
    http_config :feature
    if user_name.nil? then
      @current_user = FactoryGirl.create :user
      #Following does not work in feature specs
      #sign_in @current_user
      visit ('/')
      click_on 'Login'
      fill_in 'Email', with: @current_user.email
      fill_in 'Password', with: 'password'
      click_on 'Sign in'
      raise NotImplementedError

## Relevant portion of spec/spec_helper.rb
  config.include HTTPHelper
  config.include AuthRequestHelper, :type => :request
  config.include AuthRequestHelper, :type => :feature
  config.include AuthRequestHelper, :type => :view
  config.include AuthHelper,        :type => :controller

## spec/support/devise.rb
RSpec.configure do |config|
  config.include Devise::TestHelpers, :type => :controller
  config.include Devise::TestHelpers, :type => :view

Couple things to note: BothHTTP Authentication and Devise sign_in are  different between controller and feature specs primarily, I believe, because of Capybara handling in the feature specs (remember, the controller isn't available in feature specs with Capybara see the "Gotchas" near the bottom of the Capybara Read Me). Also check out this useful post from Thoughtbot.

Before you say anything... yes, I know it's not very DRY: I could have just included the HTTP Authentication code in each of the different authorization modules. But while working on it, I wanted the code isolated for clarity and I'm just happy to have the thing working. Feel free to clean this up if you want.

For now I'm not using the login_admin method, but will be working with that down the line.

Now I can finally get back to the real work of developing my app; hope this helps someone else.

EDIT: Thanks to the folks at thoughbot for this link which describes how to login using Warden. I haven't tried it, but it looks promising.

Sunday, May 19, 2013

Bash aliases and variables don't mix

For a long time, I've used bash aliases such as:
alias hgrep='history | grep $1'
and they've worked just fine. However, when I've tried to do something a bit more complicated, such as:
alias hprju='heroku $1 --app project $2'
it fails with an error message:
!    `--app` is not a heroku command.
Bummer! What I finally realized is that the first example isn't actually replacing $1 with what I specified on the command line. Instead, $1 has NO replacement and my additional text is simply appended to the end of the line. To see how this works, just run the following:
alias test='ls'
test /etc
and you'll get a directory listing of /etc. After some googling -- with a number of questions along the lines of "Why doesn't my first example work and not my second?" -- I found this link, where the 2nd answer had the key. The Bash Reference Manual section on Aliases clearly states:
There is no mechanism [my emphasis] for using arguments in the replacement text, as in csh. If arguments are needed, a shell function should be used (see Shell Functions).
This situation has bugged me off and on for some time and I'm glad to finally understand what's happening. Hope it helps someone else.