quo_vadis 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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