quo_vadis 1.0.2 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -3,3 +3,4 @@ pkg/*
3
3
  .bundle
4
4
  test/dummy/log/*
5
5
  test/dummy/tmp/*
6
+ rdoc/*
data/README.md CHANGED
@@ -8,14 +8,13 @@ Features:
8
8
  * No surprises: it does what you expect.
9
9
  * Easy to customise.
10
10
  * Uses BCrypt to encrypt passwords.
11
- * Sign in, sign out, forgotten password, authenticate actions.
11
+ * Sign in, sign out, forgotten password, authenticate actions, remember user between browser sessions.
12
12
 
13
13
  Forthcoming features:
14
14
 
15
- * Generate the views for you.
15
+ * Generate the views for you (for now, copy the examples given below).
16
16
  * Let you choose which model(s) to authenticate (currently `User`).
17
17
  * Let you choose the identification field (currently `username`).
18
- * Remember authenticated user across browser sessions.
19
18
  * HTTP basic/digest authentication (probably).
20
19
  * Generate (User) model plus migration if it doesn't exist.
21
20
  * Detect presence of `has_secure_password` (see below) and adapt appropriately.
@@ -58,7 +57,7 @@ Write the sign-in view. Your sign-in form must:
58
57
  * be in `app/views/sessions/new.html.:format`
59
58
  * POST the parameters `:username` and `:password` to `sign_in_url`
60
59
 
61
- You have to write the view yourself because you'd inevitably want to change whatever markup I generated for you.
60
+ You have to write the view yourself because you'd inevitably want to change whatever markup I generated for you. You can find an example in the [test app](https://github.com/airblade/quo_vadis/blob/master/test/dummy/app/views/sessions/new.html.erb).
62
61
 
63
62
  Remember to serve your sign in form over HTTPS -- to avoid [the credentials being stolen](http://blog.jgc.org/2011/01/code-injected-to-steal-passwords-in.html).
64
63
 
@@ -80,12 +79,12 @@ It'll take you about 5 minutes to implement this.
80
79
 
81
80
  On your sign-in page, link to the forgotten-password view at `forgotten_sign_in_url`.
82
81
 
83
- Write the forgotten-password view. The form must:
82
+ Write the forgotten-password view ([example](https://github.com/airblade/quo_vadis/blob/master/test/dummy/app/views/sessions/forgotten.html.erb)). The form must:
84
83
 
85
84
  * be in `app/views/sessions/forgotten.html.:format`
86
85
  * POST the parameter `:username` to `forgotten_sign_in_url`
87
86
 
88
- Now write the mailer view, i.e. the email which will be sent to your forgetful users. The view must:
87
+ Now write the mailer view, i.e. the email which will be sent to your forgetful users ([example](https://github.com/airblade/quo_vadis/blob/master/test/dummy/app/views/quo_vadis/notifier/change_password.text.erb)). The view must:
89
88
 
90
89
  * be at `app/views/quo_vadis/notifier/change_password.text.erb`
91
90
  * render `@url` somewhere (this is the link the user clicks to go to the change-password page)
@@ -98,7 +97,7 @@ Configure the default host so ActionMailer can generate the URL. In `config/env
98
97
 
99
98
  config.action_mailer.default_url_options = {:host => 'yourdomain.com'}
100
99
 
101
- Finally, write the change-password page. The form must:
100
+ Finally, write the change-password page ([example](https://github.com/airblade/quo_vadis/blob/master/test/dummy/app/views/sessions/edit.html.erb)). The form must:
102
101
 
103
102
  * be in `app/views/sessions/edit.html.:format`
104
103
  * PUT the parameter `:password` to `change_password_url(params[:token])`
@@ -106,13 +105,34 @@ Finally, write the change-password page. The form must:
106
105
 
107
106
  ## Customisation
108
107
 
109
- You can customise the flash messages in `config/locales/quo_vadis.en.yml`.
108
+ You can customise the flash messages and mailer from/subject in `config/locales/quo_vadis.en.yml`.
110
109
 
111
110
  You can customise the sign-in and sign-out redirects in `config/initializers/quo_vadis.rb`; they both default to the root route. You can also hook into the sign-in and sign-out process if you need to run any other code.
112
111
 
113
112
  If you want to add other session management type features, go right ahead: create a `SessionsController` as normal and carry on.
114
113
 
115
114
 
115
+ ## Sign up / user registration
116
+
117
+ Quo Vadis doesn't offer sign-up because that's user management, not authentication.
118
+
119
+ However if you have implemented user sign-up yourself, you need to be able to sign in a newly created user. Do this by calling `sign_in(user)` in your controller. For example:
120
+
121
+ # In your app
122
+ class UsersController < ApplicationController
123
+ def create
124
+ @user = User.new params[:user]
125
+ if @user.save
126
+ sign_in @user # <-- NOTE: sign in your user here
127
+ else
128
+ render 'new'
129
+ end
130
+ end
131
+ end
132
+
133
+ The `sign_in(user)` method will redirect the user appropriately (you can configure this in `config/initializers/quo_vadis.rb`), as well as running any sign-in hook you may have defined in the initializer.
134
+
135
+
116
136
  ## See also
117
137
 
118
138
  * Rails 3 edge's [ActiveModel::SecurePassword](https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb). It's `has_secure_password` class method is similar to Quo Vadis's `authenticates` class method.
@@ -122,3 +142,15 @@ If you want to add other session management type features, go right ahead: creat
122
142
  ## What's up with the name?
123
143
 
124
144
  Roman sentries used to challenge intruders with, "Halt! Who goes there?"; quo vadis is Latin for "Who goes there?". At least that's what my Latin teacher told us, but I was 8 years old then so I may not be remembering this entirely accurately.
145
+
146
+
147
+ ## Questions, Problems, Feedback
148
+
149
+ Please use the GitHub [issue tracker](https://github.com/airblade/quo_vadis/issues) or email me.
150
+
151
+
152
+ ## Intellectual property
153
+
154
+ Copyright 2011 Andy Stewart (boss@airbladesoftware.com).
155
+
156
+ Released under the MIT licence.
data/Rakefile CHANGED
@@ -2,6 +2,7 @@ require 'bundler'
2
2
  Bundler::GemHelper.install_tasks
3
3
 
4
4
  require 'rake/testtask'
5
+ require 'rake/rdoctask'
5
6
 
6
7
  Rake::TestTask.new(:test) do |t|
7
8
  t.libs << 'lib'
@@ -10,4 +11,12 @@ Rake::TestTask.new(:test) do |t|
10
11
  t.verbose = false
11
12
  end
12
13
 
14
+ Rake::RDocTask.new(:rdoc) do |rdoc|
15
+ rdoc.rdoc_dir = 'rdoc'
16
+ rdoc.title = 'Quo Vadis'
17
+ rdoc.options << '--line-numbers' << '--inline-source'
18
+ rdoc.rdoc_files.include('README.md')
19
+ rdoc.rdoc_files.include('app/**/*.rb')
20
+ end
21
+
13
22
  task :default => :test
@@ -5,14 +5,22 @@ module ControllerMixin
5
5
 
6
6
  private
7
7
 
8
+ # Remembers the authenticated <tt>user</tt> (in this session and future sessions).
9
+ #
10
+ # If you want to sign in a <tt>user</tt>, call <tt>QuoVadis::SessionsController#sign_in</tt>
11
+ # instead.
8
12
  def current_user=(user)
9
- session[:current_user_id] = user ? user.id : nil
13
+ remember_user_in_session user
14
+ remember_user_between_sessions user
10
15
  end
11
16
 
17
+ # Returns the authenticated user.
12
18
  def current_user
13
- @current_user ||= User.find(session[:current_user_id]) if session[:current_user_id]
19
+ @current_user ||= find_authenticated_user
14
20
  end
15
21
 
22
+ # Does nothing if we already have an authenticated user. If we don't have an
23
+ # authenticated user, it stores the desired URL and redirects to the sign in URL.
16
24
  def authenticate
17
25
  unless current_user
18
26
  session[:quo_vadis_original_url] = request.fullpath
@@ -20,4 +28,32 @@ module ControllerMixin
20
28
  redirect_to sign_in_url
21
29
  end
22
30
  end
31
+
32
+ def remember_user_in_session(user) # :nodoc:
33
+ session[:current_user_id] = user ? user.id : nil
34
+ end
35
+
36
+ def remember_user_between_sessions(user) # :nodoc:
37
+ if user && QuoVadis.remember_for
38
+ cookies.signed[:remember_me] = {
39
+ :value => [user.id, user.password_salt],
40
+ :expires => QuoVadis.remember_for.from_now,
41
+ :httponly => true
42
+ }
43
+ else
44
+ cookies.delete :remember_me
45
+ end
46
+ end
47
+
48
+ def find_authenticated_user # :nodoc:
49
+ find_user_by_session || find_user_by_cookie
50
+ end
51
+
52
+ def find_user_by_cookie # :nodoc:
53
+ User.find_by_salt(*cookies.signed[:remember_me]) if cookies.signed[:remember_me]
54
+ end
55
+
56
+ def find_user_by_session # :nodoc:
57
+ User.find(session[:current_user_id]) if session[:current_user_id]
58
+ end
23
59
  end
@@ -74,26 +74,35 @@ class QuoVadis::SessionsController < ApplicationController
74
74
  end
75
75
  end
76
76
 
77
- private
77
+ protected
78
78
 
79
+ # Signs in a user, i.e. remembers them in the session, runs the sign-in hook,
80
+ # and redirects appropriately.
81
+ #
82
+ # This method should be called when you have just authenticated <tt>user</tt>
83
+ # and you need to sign them in. For example, if a new user has just signed up,
84
+ # you should call this method to sign them in.
79
85
  def sign_in(user)
80
86
  self.current_user = user
81
87
  QuoVadis.signed_in_hook user, self
82
88
  redirect_to QuoVadis.signed_in_url(user, original_url)
83
89
  end
84
90
 
91
+ private
92
+
93
+ # Returns the URL if any which the user tried to visit before being forced to authenticate.
85
94
  def original_url
86
95
  url = session[:quo_vadis_original_url]
87
96
  session[:quo_vadis_original_url] = nil
88
97
  url
89
98
  end
90
99
 
91
- def invalid_token
100
+ def invalid_token # :nodoc:
92
101
  flash[:alert] = t('quo_vadis.flash.forgotten.invalid_token') unless t('quo_vadis.flash.forgotten.invalid_token').blank?
93
102
  redirect_to forgotten_sign_in_url
94
103
  end
95
104
 
96
- def quo_vadis_layout
105
+ def quo_vadis_layout # :nodoc:
97
106
  QuoVadis.layout
98
107
  end
99
108
 
@@ -1,6 +1,8 @@
1
1
  module QuoVadis
2
2
  class Notifier < ActionMailer::Base
3
3
 
4
+ # Sends an email to <tt>user</tt> with a link to a page where they
5
+ # can change their password.
4
6
  def change_password(user)
5
7
  @username = user.username
6
8
  @url = change_password_url user.token
@@ -7,6 +7,9 @@ module ModelMixin
7
7
  end
8
8
 
9
9
  module ClassMethods
10
+ # Adds methods to set and authenticate against a password stored encrypted by BCrypt.
11
+ # Also adds methods to generate and clear a token, used to retrieve the record of a
12
+ # user who has forgotten their password.
10
13
  def authenticates
11
14
  send :include, InstanceMethodsOnActivation
12
15
 
@@ -20,6 +23,8 @@ module ModelMixin
20
23
  scope :valid_token, lambda { |token| where("token = ? AND token_created_at > ?", token, 3.hours.ago) }
21
24
 
22
25
  instance_eval <<-END
26
+ # Returns the user with the given <tt>username</tt> if the given password is
27
+ # correct, and <tt>nil</tt> otherwise.
23
28
  def authenticate(username, plain_text_password)
24
29
  user = where(:username => username).first
25
30
  if user && user.has_matching_password?(plain_text_password)
@@ -28,17 +33,28 @@ module ModelMixin
28
33
  nil
29
34
  end
30
35
  end
36
+
37
+ def find_by_salt(id, salt) # :nodoc:
38
+ user = User.find_by_id id
39
+ if user && user.has_matching_salt?(salt)
40
+ user
41
+ else
42
+ nil
43
+ end
44
+ end
31
45
  END
32
46
  end
33
47
  end
34
48
 
35
49
  module InstanceMethodsOnActivation
36
- def password=(plain_text_password)
50
+ def password=(plain_text_password) # :nodoc:
37
51
  @password = plain_text_password
38
52
  self.password_digest = BCrypt::Password.create plain_text_password
39
53
  end
40
54
 
41
- def generate_token
55
+ # Generates a unique, timestamped token which can be used in URLs, and
56
+ # saves the record. This is part of the forgotten-password workflow.
57
+ def generate_token # :nodoc:
42
58
  begin
43
59
  self.token = url_friendly_token
44
60
  end while self.class.exists?(:token => token)
@@ -46,17 +62,31 @@ module ModelMixin
46
62
  save
47
63
  end
48
64
 
49
- def clear_token
65
+ # Clears the user's timestamped token and saves the record.
66
+ # This is part of the forgotten-password workflow.
67
+ def clear_token # :nodoc:
50
68
  update_attributes :token => nil, :token_created_at => nil
51
69
  end
52
70
 
53
- def has_matching_password?(plain_text_password)
71
+ # Returns true if the given <tt>plain_text_password</tt> is the user's
72
+ # password, and false otherwise.
73
+ def has_matching_password?(plain_text_password) # :nodoc:
54
74
  BCrypt::Password.new(password_digest) == plain_text_password
55
75
  end
56
76
 
77
+ # Returns true if the given <tt>salt</tt> is the user's salt,
78
+ # and false otherwise.
79
+ def has_matching_salt?(salt) # :nodoc:
80
+ password_salt == salt
81
+ end
82
+
83
+ def password_salt # :nodoc:
84
+ BCrypt::Password.new(password_digest).salt
85
+ end
86
+
57
87
  private
58
88
 
59
- def url_friendly_token
89
+ def url_friendly_token # :nodoc:
60
90
  ActiveSupport::SecureRandom.base64(10).tr('+/=', 'xyz')
61
91
  end
62
92
  end
@@ -1,7 +1,7 @@
1
1
  QuoVadis.configure do |config|
2
2
 
3
3
  #
4
- # Redirection URLs
4
+ # Sign in
5
5
  #
6
6
 
7
7
  # The URL to redirect the user to after s/he signs in.
@@ -18,14 +18,6 @@ QuoVadis.configure do |config|
18
18
  # to reach when they were made to authenticate.
19
19
  config.override_original_url = false
20
20
 
21
- # The URL to redirect the user to after s/he signs out.
22
- config.signed_out_url = :root
23
-
24
-
25
- #
26
- # Hooks
27
- #
28
-
29
21
  # Code to run when the user has signed in. E.g.:
30
22
  #
31
23
  # config.signed_in_hook = Proc.new do |user, controller|
@@ -40,6 +32,18 @@ QuoVadis.configure do |config|
40
32
  # end
41
33
  config.failed_sign_in_hook = nil
42
34
 
35
+ # How long to remember user across browser sessions.
36
+ # Set to <tt>nil</tt> to never remember user.
37
+ config.remember_for = 2.weeks
38
+
39
+
40
+ #
41
+ # Sign out
42
+ #
43
+
44
+ # The URL to redirect the user to after s/he signs out.
45
+ config.signed_out_url = :root
46
+
43
47
  # Code to run just before the user has signed out. E.g.:
44
48
  #
45
49
  # config.signed_out_hook = Proc.new do |user, controller|
@@ -1,3 +1,3 @@
1
1
  module QuoVadis
2
- VERSION = '1.0.2'
2
+ VERSION = '1.0.3'
3
3
  end
data/lib/quo_vadis.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  require 'quo_vadis/engine'
2
+ require 'active_support/core_ext/numeric/time'
2
3
 
3
4
  module QuoVadis
4
5
 
5
6
  #
6
- # Redirection URLs
7
+ # Sign in
7
8
  #
8
9
 
9
10
  # The URL to redirect the user to after s/he signs in.
@@ -15,7 +16,7 @@ module QuoVadis
15
16
  mattr_accessor :override_original_url
16
17
  @@override_original_url = false
17
18
 
18
- def self.signed_in_url(user, original_url)
19
+ def self.signed_in_url(user, original_url) # :nodoc:
19
20
  if original_url && !@@override_original_url
20
21
  original_url
21
22
  else
@@ -23,20 +24,11 @@ module QuoVadis
23
24
  end
24
25
  end
25
26
 
26
- # The URL to redirect the user to after s/he signs out.
27
- mattr_accessor :signed_out_url
28
- @@signed_in_url = :root
29
-
30
-
31
- #
32
- # Hooks
33
- #
34
-
35
27
  # Code to run when the user has signed in.
36
28
  mattr_accessor :signed_in_hook
37
29
  @@signed_in_hook = nil
38
30
 
39
- def self.signed_in_hook(user, controller)
31
+ def self.signed_in_hook(user, controller) # :nodoc:
40
32
  @@signed_in_hook.call(user, controller) if @@signed_in_hook
41
33
  end
42
34
 
@@ -44,15 +36,29 @@ module QuoVadis
44
36
  mattr_accessor :failed_sign_in_hook
45
37
  @@failed_sign_in_hook = nil
46
38
 
47
- def self.failed_sign_in_hook(controller)
39
+ def self.failed_sign_in_hook(controller) # :nodoc:
48
40
  @@failed_sign_in_hook.call(controller) if @@failed_sign_in_hook
49
41
  end
50
42
 
43
+ # How long to remember user across browser sessions.
44
+ mattr_accessor :remember_for
45
+ @@remember_for = 2.weeks
46
+
47
+
48
+ #
49
+ # Sign out
50
+ #
51
+
52
+ # The URL to redirect the user to after s/he signs out.
53
+ mattr_accessor :signed_out_url
54
+ @@signed_in_url = :root
55
+
56
+
51
57
  # Code to run just before the user has signed out.
52
58
  mattr_accessor :signed_out_hook
53
59
  @@signed_out_hook = nil
54
60
 
55
- def self.signed_out_hook(user, controller)
61
+ def self.signed_out_hook(user, controller) # :nodoc:
56
62
  @@signed_out_hook.call(user, controller) if @@signed_out_hook
57
63
  end
58
64
 
@@ -70,6 +76,7 @@ module QuoVadis
70
76
  @@subject = 'Change your password.'
71
77
 
72
78
 
79
+
73
80
  #
74
81
  # Miscellaneous
75
82
  #
@@ -1,7 +1,8 @@
1
1
  en:
2
2
  quo_vadis:
3
3
  flash:
4
- before_sign_in: 'Please sign in first.'
5
- after_sign_in: 'You have successfully signed in.'
6
- failed_sign_in: 'Sorry, we did not recognise you.'
4
+ sign_in:
5
+ before: 'Please sign in first.'
6
+ after: 'You have successfully signed in.'
7
+ failed: 'Sorry, we did not recognise you.'
7
8
  sign_out: 'You have successfully signed out.'
@@ -0,0 +1,97 @@
1
+ require 'test_helper'
2
+
3
+ class CookieTest < ActiveSupport::IntegrationCase
4
+
5
+ teardown do
6
+ Capybara.reset_sessions!
7
+ reset_quo_vadis_configuration
8
+ end
9
+
10
+ test 'authenticated user is remembered between browser sessions' do
11
+ user_factory 'Bob', 'bob', 'secret'
12
+ sign_in_as 'bob', 'secret'
13
+ close_browser
14
+ visit root_path
15
+ within '#topnav' do
16
+ assert page.has_content?('You are signed in as Bob.')
17
+ end
18
+ assert page.has_no_css?('div.flash')
19
+ end
20
+
21
+ test "signing in updates the remember-me cookie's expiry time" do
22
+ user_factory 'Bob', 'bob', 'secret'
23
+ sign_in_as 'bob', 'secret'
24
+ cookie_a = get_cookie('remember_me')
25
+ assert_equal 2.weeks.from_now.httpdate, cookie_a.expires.httpdate
26
+ close_browser
27
+ sleep 1 # cookie expiry times are accurate to 1 second.
28
+
29
+ sign_in_as 'bob', 'secret'
30
+ cookie_b = get_cookie('remember_me')
31
+ assert cookie_b.expires > cookie_a.expires
32
+ assert_equal cookie_a.value, cookie_b.value
33
+ end
34
+
35
+ test 'signing out prevents the user being remembered in the next browser session' do
36
+ user_factory 'Bob', 'bob', 'secret'
37
+ sign_in_as 'bob', 'secret'
38
+ visit sign_out_path
39
+ close_browser
40
+ visit new_article_path
41
+ assert_equal sign_in_path, current_path
42
+ end
43
+
44
+ test "changing user's password prevents user being remembered in the next browser session" do
45
+ user_factory 'Bob', 'bob', 'secret'
46
+ sign_in_as 'bob', 'secret'
47
+ cookie = get_cookie('remember_me')
48
+ User.last.update_attributes! :password => 'topsecret'
49
+ close_browser
50
+ visit new_article_path
51
+ assert_equal sign_in_path, current_path
52
+ end
53
+
54
+ test 'length of time user is remembered can be configured' do
55
+ QuoVadis.remember_for = 1.second
56
+ user_factory 'Bob', 'bob', 'secret'
57
+ sign_in_as 'bob', 'secret'
58
+ close_browser
59
+ sleep 2
60
+ visit new_article_path
61
+ assert_equal sign_in_path, current_path
62
+ end
63
+
64
+ test 'remembering user between sessions can be turned off' do
65
+ QuoVadis.remember_for = nil
66
+ user_factory 'Bob', 'bob', 'secret'
67
+ sign_in_as 'bob', 'secret'
68
+ close_browser
69
+ visit new_article_path
70
+ assert_equal sign_in_path, current_path
71
+ end
72
+
73
+
74
+ #
75
+ # Code below from https://github.com/nruth/show_me_the_cookies
76
+ #
77
+
78
+ def delete_cookie(cookie_name)
79
+ cookie_jar.instance_variable_get(:@cookies).reject! do |existing_cookie|
80
+ existing_cookie.name.downcase == cookie_name
81
+ end
82
+ end
83
+
84
+ def get_cookie(cookie_name)
85
+ cookie_jar.instance_variable_get(:@cookies).select do |existing_cookie|
86
+ existing_cookie.name.downcase == cookie_name
87
+ end.first
88
+ end
89
+
90
+ def cookie_jar
91
+ Capybara.current_session.driver.current_session.instance_variable_get(:@rack_mock_session).cookie_jar
92
+ end
93
+
94
+ def close_browser
95
+ delete_cookie Rails.application.config.session_options[:key]
96
+ end
97
+ end
data/test/test_helper.rb CHANGED
@@ -52,4 +52,5 @@ def reset_quo_vadis_configuration
52
52
  QuoVadis.layout = 'application'
53
53
  QuoVadis.from = 'noreply@example.com'
54
54
  QuoVadis.subject = 'Change your password'
55
+ QuoVadis.remember_for = 2.weeks
55
56
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quo_vadis
3
3
  version: !ruby/object:Gem::Version
4
- hash: 19
4
+ hash: 17
5
5
  prerelease: false
6
6
  segments:
7
7
  - 1
8
8
  - 0
9
- - 2
10
- version: 1.0.2
9
+ - 3
10
+ version: 1.0.3
11
11
  platform: ruby
12
12
  authors:
13
13
  - Andy Stewart
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2011-01-27 00:00:00 +00:00
18
+ date: 2011-02-07 00:00:00 +00:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -177,6 +177,7 @@ files:
177
177
  - test/dummy/tmp/capybara/capybara-20110124135435.html
178
178
  - test/integration/authenticate_test.rb
179
179
  - test/integration/config_test.rb
180
+ - test/integration/cookie_test.rb
180
181
  - test/integration/forgotten_test.rb
181
182
  - test/integration/helper_test.rb
182
183
  - test/integration/locale_test.rb
@@ -278,6 +279,7 @@ test_files:
278
279
  - test/dummy/tmp/capybara/capybara-20110124135435.html
279
280
  - test/integration/authenticate_test.rb
280
281
  - test/integration/config_test.rb
282
+ - test/integration/cookie_test.rb
281
283
  - test/integration/forgotten_test.rb
282
284
  - test/integration/helper_test.rb
283
285
  - test/integration/locale_test.rb