Testing authentication in Rails
Until now, the Cookbook app you generated in Chapter 1 lets any visitor create, edit, and delete recipes. That is not how production apps work and the delay was intentional so each chapter stayed focused. Adding authentication from chapter one would have stacked too many new concepts on top of testing ideas that were still new.
If you worked through the guide in order, sign-in is not a brand-new product idea either. In Chapter 4, you already signed in to the reference Cookbook clone for scenario drills. That clone already had nearly every feature your app is building towards.
This chapter adds authentication: who is logged in? Authentication answers “can this person establish a session with a valid email and password?” Chapter 13 adds authorization: given a session, may this user edit this recipe?
Why you waited until now #
Early chapters already stack plenty: Minitest folders, TDD concept (red/green), fixtures, system tests, model validations, nested forms. Authentication adds sessions, user fixtures, and different actors in the same tests.
Letting guests use full create, update, and destroy in Chapters 5 through 11 keeps each chapter small and focused. You ask “does the form work?” without having to also ask “who is allowed?” every time. Production apps usually authenticate much earlier but for teaching, the delay is a feature.
From this chapter on, guests browse only. Any signed-in user can still create, edit, update, or destroy any recipe. That is enough for authentication, but not yet for the ownership (authorization). Production apps usually let only the recipe owner edit or destroy. Those rules land in Chapter 13 so this chapter stays focused on authentication: “is the user signed in?”
What you will do in this chapter #
- Run
bin/rails generate authentication, review the generated files, then migrate new tables. - Add a fixture file for user and add Alice as a user with a password.
- Add a
current_userhelper on theAuthenticationconcern. - Sign in: skim generated controller code, write integration coverage, then red/green the sign-in system smoke.
- Review generated session helper for integration and add a new session helper for system tests.
- Sign out: integration and system tests using
sign_in_*helpers. - Guest read-only access: write denials, allow index/show pages to guests, then prove signed-in users see New, Edit, Destroy, and Remove buttons.
- TDD recipe create, update, and destroy tests (integration, then system): fail without a session (red), then sign in and go green.
- Password reset: skim generated controller, then integration and system coverage.
- Delete the scaffold
recipes_controller_test.rb(it drifts under auth; integration already owns Recipes HTTP). - Run
bin/rails test:allto ensure all tests pass, then commit.
Scenarios to automate #
These are the outcomes you will prove by the end of the chapter. Each feature follows the same shape: a main section, then integration tests, then a thin system smoke when the browser path matters. You start with sign in, then session helpers, then sign out, then guest access, then signed-in recipe writes, then password reset.
| Scenario | Expected |
|---|---|
| Sign in | Valid email and password establish a session |
| Sign in failure | Wrong password does not establish a session |
| Sign out | Session ends after DELETE |
| Guest list | GET recipes list succeeds without a session (already in recipes_integration_test.rb) |
| Guest show | GET recipe show succeeds without a session (already in recipes_integration_test.rb) |
| Guest new | Open new recipe while signed out; land on sign-in |
| Guest create | Create without a session adds no row; land on sign-in |
| Guest update | Update without a session leaves the title alone; land on sign-in |
| Guest destroy | Destroy without a session leaves the row; land on sign-in |
| Guest UI | No New or Destroy on the list; no Edit, Destroy, or ingredient/step Remove on show |
| Signed-in UI | Signed-in user sees New and Destroy on the list; Edit, Destroy, and Remove on show |
| Signed-in CRUD | Chapter 7 create, update, and destroy still work after the introduction of authentication |
| Password reset request | Known email enqueues reset mail and redirects to sign-in; unknown email sends no mail |
| Password reset update | Valid token + matching passwords change the digest and redirect to sign-in |
| Password reset UI | Forgot-password form, set a new password, sign in with the new password |
Generate authentication #
Rails 8 shipped with a lot of goodies and one of them was the built-in authentication generator (finally, bye bye Devise!). A single command bin/rails generate authentication gives you users, sessions, sign-in, and password reset. It also ships with an Authentication concern that ensures user is logged in before accessing any action inside a controller. The generator is deliberately simple so you can customize it to your needs.
Run the following command from your app root to add authentication to the app:
bin/rails generate authentication
What the generator added #
These are the main files and edits you get in the Cookbook app after running the generator. You do not need to memorize every path, just skim the list so you know where files related to authentication live and how they are wired together.
| Path | What it does |
|---|---|
app/models/user.rb |
Responsible for user accounts in the app. Uses has_secure_password that adds methods to set and authenticate against a BCrypt password, has_many :sessions, and normalizes email_address (strip + downcase) before save. |
app/models/session.rb |
One database row per signed-in browser. Belongs to a user. The signed cookie points at this row. |
app/models/current.rb |
Responsible for holding the current user for the request. Controllers and views reach the signed-in user through Current.user. |
app/controllers/concerns/authentication.rb |
Code to enforce authentication lives here. Requires a session by default, lets controllers opt-out of authentication for guests with allow_unauthenticated_access, resumes a session from the cookie, and exposes helpers like authenticated? to check if the user is signed in or not. |
app/controllers/application_controller.rb |
Includes the Authentication concern so every controller inherits the before-action for enforcing authentication (edited file). |
app/controllers/sessions_controller.rb |
Controller responsible for logging in and out of the app. |
app/controllers/passwords_controller.rb |
Responsible for handling the forgot password flow. Request a reset email, then set a new password from the token link. |
app/mailers/passwords_mailer.rb |
Responsible for sending the password reset email with a signed token link. |
app/views/sessions/*.html.erb |
Views for session controller. |
app/views/passwords/*.html.erb |
Views for password controller. |
app/views/passwords_mailer/*.html.erb |
Views for password reset email. |
db/migrate/*_create_users.rb |
Creates the users table with columns email_address, password_digest, and timestamps. |
db/migrate/*_create_sessions.rb |
Creates the sessions table with columns user_id, optional ip_address and user_agent. |
test/test_helpers/session_test_helper.rb |
Session helpers for integration tests. sign_in_as(user) sets the signed session cookie; sign_out clears it. Loaded only for controller/integration tests, not available in system tests. |
test/fixtures/users.yml |
Starter user fixture from the generator. You will replace it with custom user record in this chapter. |
Review new migration files #
Open the two migrations the generator added db/migrate/*_create_users.rb and db/migrate/*_create_sessions.rb and take a quick look at the columns for each table.
A production app often grows more columns on users table (things like first_name, last_name, address, etc.). But you will stay on the generator defaults for the Cookbook app to keep the setup simple.
users #
| Column | Purpose |
|---|---|
email_address |
Unique login identity (null: false, unique index) |
password_digest |
bcrypt digest for has_secure_password (null: false) |
created_at / updated_at |
Timestamps |
sessions #
| Column | Purpose |
|---|---|
user_id |
Foreign key to the signed-in user (null: false) |
ip_address |
Optional client IP stored when the session starts |
user_agent |
Optional browser / client string stored when the session starts |
created_at / updated_at |
Timestamps |
Once you are comfortable with the columns, migrate the new tables with the following command:
bin/rails db:migrate
Change root url for the app #
Successful sign-in redirects to root_url that defaults to the welcome page for a brand new Rails app. Until you set a root route manually, redirects will keep landing on the default Rails welcome page. Point root at the recipes list so sign-in drops you on Cookbook home instead of the unused welcome page. In config/routes.rb, find the commented root "posts#index" example, uncomment it, and point it at the recipes list:
# config/routes.rb
root "recipes#index"
From here on, the recipes list will be the app home after sign-in.
User fixtures #
Authentication generator ships with a starter user fixture at test/fixtures/users.yml. It declares users as one and two by default, I am not a fan of that setup so update it to use a real name instead. Open the test/fixtures/users.yml file and replace the one: with the following:
# test/fixtures/users.yml
alice:
email_address: alice@example.com
password_digest: <%= password_digest %>
You can leave the two: entry as it is. You will replace it with another user in the next chapter.
Add a current_user helper #
The generator stores the signed-in user on Current.user (attribute on the Current model). Because I come from Devise background I like current_user to access the signed-in user instead of Current.user which I reads and writes longer. You can use Current.user in the view if you want, it’s just a matter of preference.
Find the existing helper_method :authenticated? line and include :current_user. Then add the current_user method just below the authenticated? method at app/controllers/concerns/authentication.rb:
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?, :current_user
end
# ... class_methods and other private methods unchanged ...
private
def authenticated?
resume_session
end
def current_user
return nil unless authenticated?
return @current_user if defined?(@current_user)
@current_user = Current.user
end
# ... rest of the concern unchanged ...
end
This is what’s happening in the code above:
helper_method :current_userexposes this helper method to views and controllers.current_userreturnsnilwith no session. With a session it memoizesCurrent.useronce per request.
Going forward, you will use the current_user helper in the view and controller to access the signed-in user instead of Current.user.
Sign in to the app #
With the authentication generator in place, you can now sign users in to the app. In this section, you will test the actual sign in flow over HTTP and in the browser.
Controller tests #
The generator adds a session test at test/controllers/sessions_controller_test.rb. Skim it for an idea of what is covered. We will not dig into controller tests for authentication here and focus on integration and system tests like we did in the previous chapters. You can delete the generated controller test file if you want. That will not break the test suite nor the app.
Integration tests #
With integration tests, you prove the sign-in flow works over HTTP.
Before you write the test, skim the session controller generated by the authentication generator so you know what to test.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end
This is what’s happening in the code above:
allow_unauthenticated_access only: %i[ new create ]skips the authentication check for thenewandcreateactions, allowing guests to reach the sign-in form and submit it.allow_unauthenticated_accesscomes from theAuthenticationconcern atapp/controllers/concerns/authentication.rb.rate_limitcaps sign-in attempts, then redirects back to sign-in with an alert. This is a security measure to prevent brute force attacks.newrenders the sign-in form.createauthenticates withUser.authenticate_by, starts a session, and redirects toafter_authentication_url(defaults toroot_url). If the email or password is incorrect, it redirects back to the sign-in page with an alert.destroyterminates the session (signs out the user) and redirects to the sign-in page.
What counts as working? #
| Flow | Expected |
|---|---|
| Valid sign in | Open sign-in form, POST correct password, redirect to root_url |
| Invalid password | POST wrong password, redirect to new_session_url, sign-in form still present |
Add scenarios to the test file #
Create a session integration test file with nano test/integration/session_integration_test.rb and add the following scenarios:
# test/integration/session_integration_test.rb
# Actor: visitor with an account
# Starting point: not signed in
# Action: GET sign-in form, then POST valid email and password
# Expected outcome: redirect to root_url; session established
# test "signs in" do
# end
# Actor: visitor with an account
# Starting point: not signed in
# Action: POST wrong password
# Expected outcome: redirect to new_session_url; form still present
# test "rejects invalid password" do
# end
Add the integration tests #
Replace the test body with the following:
# test/integration/session_integration_test.rb
require "test_helper"
class SessionIntegrationTest < ActionDispatch::IntegrationTest
test "signs in" do
get new_session_url
assert_response :success
post session_url, params: {
email_address: users(:alice).email_address,
password: "password"
}
assert_redirected_to root_url
follow_redirect!
assert_response :success
end
test "rejects invalid password" do
post session_url, params: {
email_address: users(:alice).email_address,
password: "wrong"
}
assert_redirected_to new_session_url
follow_redirect!
assert_select "form"
end
end
This is what’s happening in the code above:
signs inopens the form, posts Alice’s fixture email and the shared test password, then follows the redirect toroot_url.rejects invalid passwordposts a bad password and expects a redirect back tonew_session_url, then checks the form is still there.
Run the session integration test, you should see 0 failures and 0 errors.
bin/rails test test/integration/session_integration_test.rb
System tests #
Integration tests proved the sign-in over HTTP. One system test proves the real form and the UI used by the human to sign in to the app is working correctly.
What counts as working? #
| Flow | Expected |
|---|---|
| Sign in to the app | Fill Alice credentials, land on recipes list, see Sign out |
Add scenarios to the test file #
Create a system test file with nano test/system/sessions_test.rb and add the following scenarios:
# test/system/sessions_test.rb
# Actor: visitor with an account
# Starting point: signed out
# Action: visit sign-in, fill Alice credentials, click Sign in
# Expected outcome: recipes list heading and Sign out
# test "signs in" do
# end
Red: add the system test #
Replace the test body with the following:
# test/system/sessions_test.rb
require "application_system_test_case"
class SessionsTest < ApplicationSystemTestCase
test "signs in" do
visit new_session_url
fill_in "Enter your email address", with: users(:alice).email_address
fill_in "Enter your password", with: "password"
click_on "Sign in"
assert_selector "h1", text: "Recipes"
assert_text "Sign out"
end
end
Run the system test:
bin/rails test test/system/sessions_test.rb
You want a failure on assert_text "Sign out". The failure is expected since the view does not yet have the button to sign out of the app.
F
Failure:
SessionsTest#test_signs_in [test/system/sessions_test.rb]:
expected to find text "Sign out" in "Recipes..."
Green: add Sign in and Sign out to the application layout #
Update the app/views/layouts/application.html.erb file to include the sign in link and sign out button. Add the following code inside <body> tag and before the yield statement:
<%# app/views/layouts/application.html.erb %>
<html>
<!-- Other code -->
<body>
<% if authenticated? %>
<p>Signed in as <%= current_user.email_address %></p>
<%= button_to "Sign out", session_path, method: :delete %>
<% else %>
<%= link_to "Sign in", new_session_path if request.path != new_session_path %>
<% end %>
<%= yield %>
</body>
</html>
This is what’s happening in the code above:
authenticated?is a helper exposed by theAuthenticationconcern that checks if the user is signed in or not.button_to "Sign out"signs the user out of the app.- Guests see Sign in unless they are either already on the sign-in page or signed in to the app.
Run the system test again, you want 0 failures and 0 errors.
bin/rails test test/system/sessions_test.rb
Sign-in helper for tests #
You just proved the real sign-in form. For sign out and other protected routes, you will need a way to sign in the user first. That can be done in two ways:
- By driving the sign-in form on every request.
- By setting the signed
session_iddirectly before every request (this is how Rails knows the user is signed in).
Reserve the first approach for testing the sign-in form itself (the section you just finished). Prefer the second for sign out, protected routes for the recipe, and other tests that only need a session already in place. Second approach is more efficient and avoids the overhead of driving the sign-in form on every request while still ensuring the user is signed in.
For Integration tests #
The authentication generator already ships with a session helper required for integration tests to sign in the user. This is what the session helper at test/test_helpers/session_test_helper.rb looks like:
# test/test_helpers/session_test_helper.rb
module SessionTestHelper
def sign_in_as(user)
Current.session = user.sessions.create!
ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
cookie_jar.signed[:session_id] = Current.session.id
cookies["session_id"] = cookie_jar[:session_id]
end
end
def sign_out
Current.session&.destroy!
cookies.delete("session_id")
end
end
ActiveSupport.on_load(:action_dispatch_integration_test) do
include SessionTestHelper
end
This is what’s happening in the code above:
sign_in_as(user)creates a realSessionrow and sets the signed cookie the Authentication concern reads. You can use this helper when a test needs a signed-in user without going through the sign in flow via the form.sign_outdestroys the current session and deletes the cookie.ActiveSupport.on_loadincludes the module on everyActionDispatch::IntegrationTest, so session and recipe tests can callsign_in_aswithout having toinclude SessionTestHelperin each test file.
For System tests #
System tests are not generated by default in recent Rails versions, which also means Rails doesn’t generate a session helper required for system tests. You need to add it manually.
Replace the content of test/application_system_test_case.rb with the following to include the session helper for signing in the user to the app:
# test/application_system_test_case.rb
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driver = ENV["HEADFUL"] == "1" ? :chrome : :headless_chrome
driven_by :selenium, using: driver, screen_size: [ 1400, 1400 ]
teardown { Capybara.reset_sessions! }
def sign_in_to_ui_as(user)
Current.session = user.sessions.create!
ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
cookie_jar.signed[:session_id] = Current.session.id
visit new_session_url
page.driver.browser.manage.add_cookie(
name: :session_id,
value: cookie_jar[:session_id],
sameSite: :Lax,
httpOnly: true
)
end
end
end
This is what’s happening in the code above:
sign_in_to_ui_as(user)builds a realSessionrow, visits the sign-in page so the browser has a host, then injects the signedsession_idcookie to the browser.teardown { Capybara.reset_sessions! }clears browser cookies between tests so a leftover session does not leak.- The method lives in
ApplicationSystemTestCasewhich is the base class for all system tests, so every system test can call it directly.
Sign out of the app #
To exercise the sign out path, you need an existing session first. With the sign_in_as helper in place, you can easily sign in to the app with a single line of code. You can then use the delete session_url to sign out the user.
Integration tests #
What counts as working? #
| Flow | Expected |
|---|---|
| Sign out | After sign_in_as, DELETE session redirects to new_session_url |
Add the scenario #
Append the following scenario to test/integration/session_integration_test.rb (same file as sign in):
# test/integration/session_integration_test.rb
# Actor: signed-in user
# Starting point: alice session from sign_in_as
# Action: DELETE session
# Expected outcome: redirect to new_session_url
# test "signs out" do
# end
Add the integration test #
Replace the body of test "signs out" with the following:
# test/integration/session_integration_test.rb
test "signs out" do
sign_in_as users(:alice)
delete session_url
assert_redirected_to new_session_url
assert_empty cookies["session_id"]
end
This is what’s happening in the code above:
sign_in_as users(:alice)establishes the session cookie without driving the form again.delete session_urlhitsSessionsController#destroy, which terminates the session and redirects the user to the sign-in page.assert_empty cookies["session_id"]checks that the session cookie is deleted after signing out.
Run the full session integration test:
bin/rails test test/integration/session_integration_test.rb
You want 0 failures and 0 errors across sign in, bad password, and sign out.
System tests #
The sign-in system test already requires a visible Sign out button after a successful form login. Now, prove that the user is signed out after clicking the Sign out button and ensure they land on the sign-in page:
Add the scenario #
Append the following scenario to test/system/sessions_test.rb:
# test/system/sessions_test.rb
# Actor: Alice, signed in via UI helper
# Starting point: recipes list
# Action: click Sign out
# Expected outcome: land on sign-in path; Sign in button visible
# test "signs out" do
# end
Add the system test #
Replace the body of test "signs out" with the following:
# test/system/sessions_test.rb
test "signs out" do
sign_in_to_ui_as users(:alice)
visit recipes_url
click_on "Sign out"
assert_current_path new_session_path
assert_button "Sign in"
end
This is what’s happening in the code above:
sign_in_to_ui_as users(:alice)signs in the user to the app.visit recipes_urlvisits the recipes list page.click_on "Sign out"clicks the Sign out button.assert_current_path new_session_pathchecks that the user is redirected to the sign-in page.
Run the full system test, you want 0 failures and 0 errors.
bin/rails test test/system/sessions_test.rb
Next, quickly surf in the browser to confirm sign in and sign out:
- Start
bin/devif the server is not running. - Visit
http://localhost:3000/session/new(or click the Sign in link in the page). - Sign in as Alice with email
alice@example.comand passwordpassword. - Confirm you land on the recipes list, see fixture titles, and see Sign out button.
- Click Sign out. Confirm you land on the sign-in page and see the Sign in button.
With sign in, helpers, and sign out in place, you can now lock the recipe write paths so guests can only browse while signed in users can create, edit and destroy recipes.
Guest can only read recipes #
Until now, guests can browse the list, open a recipe, and create, edit, or destroy recipes. That changes from this point forward: you lock write actions so guests can only read. They should not reach new, create, edit, update, or destroy: any section that modifies the database.
Integration tests #
ApplicationController already includes the Authentication concern, so every recipe action requires a session (logged in user) by default. This is why the denials paths for recipe modification actions for guests will be true and tests for those will pass once you write them.
The red step you need to fix is that your older list and show tests in recipes_integration_test.rb still expect guests to open those pages and they will now fail because these actions require users to be signed in to the app.
What counts as working? #
| Flow | Expected |
|---|---|
| Guest list | Open the recipes list without a session; recipe titles are visible; no New recipe or Destroy this recipe. Covered in recipes_integration_test.rb. |
| Guest show | Open pancakes show without a session; recipe title is visible; no Edit or Destroy. Nested Remove is covered with ingredients and steps. Covered in recipes_integration_test.rb. |
| Guest new | Open new recipe while signed out; redirected to sign-in |
| Guest create | Submit a create without a session; no new row; redirected to sign-in |
| Guest update | Submit an update without a session; title unchanged; redirected to sign-in |
| Guest destroy | Submit a destroy without a session; recipe count unchanged in the database; redirected to sign-in |
| Signed-in list UI | User signed in; New recipe and Destroy this recipe are present on the list |
| Signed-in show UI | User signed in; Edit, Destroy, and ingredient/step Remove are present on show |
Add scenarios to the test file for guest access restrictions #
Create a new test file with nano test/integration/recipe_access_integration_test.rb and add the following scenarios:
# test/integration/recipe_access_integration_test.rb
# Actor: guest
# Starting point: recipe count known
# Action: open new form, then POST create without session
# Expected outcome: both redirect to sign-in; no new row
# test "guest cannot create a recipe" do
# end
# Actor: guest
# Starting point: recipes(:pancakes) with known title
# Action: open edit form, then PATCH update without session
# Expected outcome: both redirect to sign-in; title unchanged
# test "guest cannot edit a recipe" do
# end
# Actor: guest
# Starting point: recipes(:lentil_soup) exists
# Action: submit destroy without session
# Expected outcome: row count unchanged; redirect to sign-in
# test "guest cannot destroy a recipe" do
# end
Add the access integration tests #
Fill in the test bodies with the following test cases:
# test/integration/recipe_access_integration_test.rb
require "test_helper"
class RecipeAccessIntegrationTest < ActionDispatch::IntegrationTest
test "guest cannot create a recipe" do
get new_recipe_url
assert_redirected_to new_session_url
assert_no_difference("Recipe.count") do
post recipes_url, params: { recipe: { title: "Sneaky soup" } }
end
assert_redirected_to new_session_url
end
test "guest cannot edit a recipe" do
recipe = recipes(:pancakes)
original_title = recipe.title
get edit_recipe_url(recipe)
assert_redirected_to new_session_url
patch recipe_url(recipe), params: { recipe: { title: "Hacked title" } }
assert_redirected_to new_session_url
assert_equal original_title, recipe.reload.title
end
test "guest cannot destroy a recipe" do
recipe = recipes(:lentil_soup)
assert_no_difference("Recipe.count") do
delete recipe_url(recipe)
end
assert_redirected_to new_session_url
end
end
This is what’s happening in the code above:
- Each test stays signed out on purpose. Guests must bounce to sign-in.
- Create and destroy use
assert_no_difference("Recipe.count")because those actions add or remove a row. - Edit is a title-only
patch. Row count would stay the same even if the hack succeeded, so the test keepsoriginal_titleand assertsrecipe.reload.titleinstead.
Run the integration test for guest access restrictions, you want 0 failures and 0 errors.
bin/rails test test/integration/recipe_access_integration_test.rb
Red: guest browse in recipes_integration_test #
List and show still live in test/integration/recipes_integration_test.rb. After authentication, those older tests fail because guests can no longer open index or show pages.
Update the list, show, and nested-detail tests so they assert that create, edit, destroy and remove buttons are not present for guests.
# test/integration/recipes_integration_test.rb
test "visits the list" do
# ... existing code unchanged ...
assert_select "a", text: "New recipe", count: 0
assert_select "button", text: "Destroy this recipe", count: 0
end
test "shows a recipe" do
# ... existing code unchanged ...
assert_select "a", text: "Edit this recipe", count: 0
assert_select "button", text: "Destroy this recipe", count: 0
end
test "shows ingredients and steps on the recipe page" do
# ... existing code unchanged ...
assert_select "button", text: "Remove", count: 0
end
Run the recipe integration tests for list, show, and nested details:
bin/rails test test/integration/recipes_integration_test.rb -i "/test_visits_the_list|test_shows_a_recipe|test_shows_ingredients_and_steps_on_the_recipe_page/"
You want failures. Guests still hit the global require_authentication on index and show, so the response is a redirect to sign-in instead of :success.
FFF
Failure:
RecipesIntegrationTest#test_visits_the_list [test/integration/recipes_integration_test.rb:7]:
Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/session/new>
Response body:
Failure:
RecipesIntegrationTest#test_shows_a_recipe [test/integration/recipes_integration_test.rb:16]:
Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/session/new>
Response body:
Failure:
RecipesIntegrationTest#test_shows_ingredients_and_steps_on_the_recipe_page [test/integration/recipes_integration_test.rb:24]:
Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/session/new>
Response body:
That is your red step for guest browsing the recipe list and show pages.
Green: allow guest browse and hide buttons to modify a recipe #
Update the RecipesController to allow guests to browse the recipe list and show pages. Add the allow_unauthenticated_access helper to the controller:
# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
allow_unauthenticated_access only: %i[index show]
before_action :set_recipe, only: %i[show edit update destroy]
# ... existing actions unchanged
end
allow_unauthenticated_access only: %i[index show] skips require_authentication for browsing. Every other action still needs a session. The helper lives in the Authentication concern and has the following code:
# app/controllers/concerns/authentication.rb
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
Next, you need to hide the buttons to modify a recipe for guests: New recipe button in the index view and Edit and Destroy buttons in the show view, plus ingredient and step Remove buttons in the show view.
Update the index view at app/views/recipes/index.html.erb and wrap the New recipe link in an if block to only show it to signed in users:
<%# app/views/recipes/index.html.erb %>
<% if authenticated? %>
<%= link_to "New recipe", new_recipe_path %>
<% end %>
Update the recipe partial at app/views/recipes/_recipe.html.erb and wrap the Destroy button in an if block to only show it to signed in users:
<%# app/views/recipes/_recipe.html.erb %>
<% if authenticated? %>
<%= button_to "Destroy this recipe", destroy_url, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<% end %>
Update the show view at app/views/recipes/show.html.erb and wrap the Edit this recipe link, Remove buttons for each ingredient and step in an if block to only show them to signed in users. Replace the content of the file with the following:
<%# app/views/recipes/show.html.erb (bottom links) %>
<p style="color: green"><%= notice %></p>
<%= render @recipe, for_show: true %>
<% if @recipe.ingredients.any? %>
<h2>Ingredients</h2>
<ul id="ingredients">
<% @recipe.ingredients.each do |ingredient| %>
<li id="<%= dom_id(ingredient) %>">
<%= ingredient.name %><% if ingredient.quantity.present? %> (<%= number_with_precision(ingredient.quantity, precision: 2, strip_insignificant_zeros: true) %><% if ingredient.unit.present? %> <%= ingredient.unit %><% end %>)<% end %>
<% if authenticated? %>
<%= button_to "Remove",
recipe_ingredient_path(@recipe, ingredient),
method: :delete,
data: { turbo_confirm: "Remove this ingredient?" } %>
<% end %>
</li>
<% end %>
</ul>
<% end %>
<% if @recipe.steps.any? %>
<h2>Steps</h2>
<ol id="steps">
<% @recipe.steps.order(:position).each do |step| %>
<li id="<%= dom_id(step) %>">
<%= step.instruction %>
<% if authenticated? %>
<%= button_to "Remove",
recipe_step_path(@recipe, step),
method: :delete,
data: { turbo_confirm: "Remove this step?" } %>
<% end %>
</li>
<% end %>
</ol>
<% end %>
<div>
<% if authenticated? %>
<%= link_to "Edit this recipe", edit_recipe_path(@recipe) %>
<% end %>
<%= link_to "Back to recipes", recipes_path %>
</div>
authenticated? hides New, Destroy, Edit, and Remove from guests while signed-in users still see them.
Run list, show, and nested-detail tests again, you want 0 failures and 0 errors.
bin/rails test test/integration/recipes_integration_test.rb -i "/test_visits_the_list|test_shows_a_recipe|test_shows_ingredients_and_steps_on_the_recipe_page/"
Prove signed-in users still see change buttons #
Guest list and show prove the controls are gone when signed out. You still need the other side: a signed-in user must see them. Update the recipe_access_integration_test.rb file with the following scenarios:
# test/integration/recipe_access_integration_test.rb
# Actor: Alice, signed in
# Starting point: session from sign_in_as
# Action: open recipes list
# Expected outcome: New recipe and Destroy this recipe present
# test "signed-in user sees new recipe on the list" do
# end
# Actor: Alice, signed in
# Starting point: session from sign_in_as
# Action: open pancakes show
# Expected outcome: Edit, Destroy, and Remove present
# test "signed-in user sees edit, destroy, and remove on show" do
# end
Replace the test bodies with the following test cases:
# test/integration/recipe_access_integration_test.rb
test "signed-in user sees new recipe on the list" do
sign_in_as users(:alice)
get recipes_url
assert_response :success
assert_select "a", text: "New recipe"
assert_select "button", text: "Destroy this recipe"
end
test "signed-in user sees edit, destroy, and remove on show" do
sign_in_as users(:alice)
get recipe_url(recipes(:pancakes))
assert_response :success
assert_select "a", text: "Edit this recipe"
assert_select "button", text: "Destroy this recipe"
assert_select "button", text: "Remove"
end
This is what’s happening in the code above:
sign_in_as users(:alice)establishes a session before the GET.- These tests mirror the guest
count: 0asserts inrecipes_integration_test.rb. Guests must not see the controls. Signed-in users must.
Run the full access test file, you want 0 failures and 0 errors.
bin/rails test test/integration/recipe_access_integration_test.rb
Next, quickly surf in the browser to confirm the changes:
- Start
bin/devif the server is not running. - Visit
http://localhost:3000/recipeswhile signed out. Confirm recipe titles appear and buttons for New recipe and Destroy this recipe are gone. - Open Fluffy pancakes. Confirm the title appears and Edit this recipe, Destroy this recipe, and Remove are gone.
- Sign in as Alice with email
alice@example.comand passwordpassword. Confirm New and Destroy return on the list, and Edit, Destroy, and Remove return on the recipe show page.
System tests #
You do not need a system test for guest restrictions or signed-in button visibility. The integration tests above already cover both sides.
Guest read-only HTTP and UI now match: redirects, unchanged counts and hidden buttons for guests.
Guests browse, recipe modification buttons are shown for signed in users, and the tests prove both.
You locked guest create, edit, and destroy, hid buttons for guests, and proved signed in users still see New, Edit, Destroy, and Remove. If that habit is sticking, fund the next chapter and help keep the guide free.
One-time support via Stripe. No account required.
Signed-in users can still change recipes #
Guests are locked out of create, update, and destroy while signed-in users still see New, Edit, Destroy, and Remove. But your old tests for create, update, and destroy still assume a guest can change recipes, so they fail until you add a session. Fix that next.
Integration tests #
Red: tests for create, update, and destroy recipe fail without a session #
Run existing integration tests for create, update, and destroy recipe to see failures due to missing session.
bin/rails test test/integration/recipes_integration_test.rb -i "/test_creates_a_recipe|test_updates_a_recipe|test_destroys_a_recipe/"
Without a session, create hits require_authentication on get new_recipe_url and redirects to sign-in instead of :success. You will see failures like this for each test:
FFFF
Failure:
RecipesIntegrationTest#test_creates_a_recipe [test/integration/recipes_integration_test.rb:28]:
Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/session/new>
Response body:
Green: sign in before create, update, and destroy #
Add sign_in_as users(:alice) at the start of every test for create, update, and destroy recipe.
# test/integration/recipes_integration_test.rb
test "creates a recipe" do
sign_in_as users(:alice)
# ... rest of the test remains unchanged
end
test "creates a recipe with an ingredient and a step" do
sign_in_as users(:alice)
get new_recipe_url
assert_response :success
# ... rest unchanged from Chapter 9
end
test "does not create a recipe with invalid data" do
sign_in_as users(:alice)
# ... rest of the test remains unchanged
end
test "does not create a recipe with invalid ingredient" do
sign_in_as users(:alice)
# ... rest of the test remains unchanged
end
test "does not create a recipe with invalid step" do
sign_in_as users(:alice)
# ... rest of the test remains unchanged
end
test "updates a recipe" do
sign_in_as users(:alice)
recipe = recipes(:pancakes)
# ... rest of the test remains unchanged
end
test "adds an ingredient and a step when editing a recipe" do
sign_in_as users(:alice)
recipe = recipes(:pancakes)
# ... rest of the test remains unchanged
end
test "destroys a recipe" do
sign_in_as users(:alice)
recipe = recipes(:lentil_soup)
# ... rest of the test remains unchanged
end
test "removes existing ingredient and step when updating a recipe" do
sign_in_as users(:alice)
# ... rest of the test remains unchanged
end
test "removes ingredients and steps from the detail page" do
sign_in_as users(:alice)
# ... rest of the test remains unchanged
end
This is what’s happening in the code above:
sign_in_as users(:alice)runs first so the session cookie is set beforeget,post,patch, ordelete.- Invalid-data and nested tests follow the same pattern: sign in, then the same params you already used in earlier chapters.
- List, show, and filter tests stay unchanged since they are not affected by the authentication changes and work without user signing in.
Run the full integration file. You want 0 failures and 0 errors.
bin/rails test test/integration/recipes_integration_test.rb
System tests #
Same as integration tests, system tests for create, update, and destroy recipe fail without a session. List and show tests stay unchanged since they are not affected by the authentication changes and work without user signing in.
Red: system tests for create, update, and destroy recipe fail without sign-in #
Run the system tests for create, update, and destroy recipe to see failures due to missing session.
bin/rails test test/system/recipes_test.rb -i "/test_creates_a_recipe|test_updates_a_recipe|test_destroys_a_recipe/"
You want failures. The browser lands on the sign-in page, so Capybara cannot find the Title field or the Destroy this recipe button.
EEEE
Error:
RecipesTest#test_creates_a_recipe:
Capybara::ElementNotFound: Unable to find field "Title" that is not disabled
Error:
RecipesTest#test_destroys_a_recipe:
Capybara::ElementNotFound: Unable to find link or button "Destroy this recipe"
Green: sign in before create, update, and destroy recipe #
Add sign_in_to_ui_as users(:alice) as the first line of each test for create, update, and destroy recipe while keeping other lines unchanged.
# test/system/recipes_test.rb
test "creates a recipe" do
sign_in_to_ui_as users(:alice)
# ... rest of the test remains unchanged
end
test "updates a recipe" do
sign_in_to_ui_as users(:alice)
# ... rest of the test remains unchanged
end
test "destroys a recipe" do
sign_in_to_ui_as users(:alice)
# ... rest of the test remains unchanged
end
test "destroys a recipe from the list" do
sign_in_to_ui_as users(:alice)
# ... rest of the test remains unchanged
end
test "removes ingredients and steps from the detail page" do
sign_in_to_ui_as users(:alice)
# ... rest of the test remains unchanged
end
This is what’s happening in the code above:
- Create, update, destroy and remove ingredients and steps call
sign_in_to_ui_as users(:alice)first so Capybara signs in the user before performing the action.
Run the full system test. You want 0 failures and 0 errors.
bin/rails test test/system/recipes_test.rb
Reset a forgotten password #
The authentication generator also ships password reset: request a reset email, open the token link, set a new password. Skim the generated controller before writing tests:
# app/controllers/passwords_controller.rb
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end
Here is what’s happening in the code above:
allow_unauthenticated_accessallows the controller to be accessed without a session.before_action :set_user_by_token, only: %i[ edit update ]sets the user by the password reset token.rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }limits the number of password reset requests to 10 tries per 3 minutes.def newrenders the new password reset form.def createfinds the user by email address and sends a password reset email if the user exists.def editrenders the edit password form.def updateupdates the user’s password and redirects to the sign-in page.
Controller tests #
The generator also adds test/controllers/passwords_controller_test.rb similar to how it does for sessions. You can skim it if you want but we will ignore it for now as we don’t prefer writing integration tests for authentication over controller tests.
You can keep the file, or delete it if you prefer one home for password-reset HTTP in test/integration/. The integration tests below are the ones you will maintain as Cookbook coverage.
Integration tests #
PasswordsController#create always redirects to sign-in with the same notice and only enqueues mail when the email matches a user. PasswordsController#update uses password_reset_token in the URL, clears all sessions on success, and redirects back to edit when confirmation does not match.
What counts as working? #
| Flow | Expected |
|---|---|
| Known email | POST reset request enqueues PasswordsMailer#reset and redirects to sign-in |
| Unknown email | Same redirect and notice shape; no email enqueued (do not leak whether the account exists) |
| Valid token update | Matching password + confirmation change password_digest and redirect to sign-in |
| Mismatched password confirmation | Digest unchanged; redirect back to the edit form |
Add scenarios to the test file #
Create a new test file with nano test/integration/password_integration_test.rb and add the following scenarios:
# test/integration/password_integration_test.rb
# Actor: guest with an account
# Action: POST passwords with Alice's email
# Expected outcome: PasswordsMailer#reset enqueued; redirect to sign-in
# test "requests a password reset for a known email" do
# end
# Actor: guest
# Action: POST passwords with an unknown email
# Expected outcome: no email; redirect to sign-in
# test "does not send a password reset email for an unknown email" do
# end
# Actor: guest with a valid reset token
# Action: PUT new matching passwords
# Expected outcome: password_digest changes; redirect to sign-in
# test "updates the password with a valid token" do
# end
# Actor: guest with a valid reset token
# Action: PUT mismatched password confirmation
# Expected outcome: digest unchanged; redirect to edit password
# test "rejects a mismatched password confirmation" do
# end
Add the integration tests #
Replace the test bodies with the following test cases:
# test/integration/password_integration_test.rb
require "test_helper"
class PasswordIntegrationTest < ActionDispatch::IntegrationTest
test "requests a password reset for a known email" do
assert_enqueued_email_with PasswordsMailer, :reset, args: [users(:alice)] do
post passwords_url, params: { email_address: users(:alice).email_address }
end
assert_redirected_to new_session_url
end
test "does not send a password reset email for an unknown email" do
assert_no_enqueued_emails do
post passwords_url, params: { email_address: "missing@example.com" }
end
assert_redirected_to new_session_url
end
test "updates the password with a valid token" do
user = users(:alice)
token = user.password_reset_token
assert_changes -> { user.reload.password_digest } do
put password_url(token), params: {
password: "new-password",
password_confirmation: "new-password"
}
end
assert_redirected_to new_session_url
end
test "rejects a mismatched password confirmation" do
user = users(:alice)
token = user.password_reset_token
assert_no_changes -> { user.reload.password_digest } do
put password_url(token), params: {
password: "new-password",
password_confirmation: "different"
}
end
assert_redirected_to edit_password_url(token)
end
end
This is what’s happening in the code above:
- Known and unknown emails share the same redirect with only a difference in enqueue side. This matches the generator’s privacy-minded notice.
user.password_reset_tokenbuilds the signed token the edit/update URLs expect.- A successful update changes the password digest. A mismatched confirmation leaves it alone.
Run the test:
bin/rails test test/integration/password_integration_test.rb
You want 0 failures and 0 errors. The generator already implemented the controller, so this suite should be green once the tests match the redirects and mail enqueue behavior above.
System tests #
One browser smoke covers the path a human uses: Forgot password from the sign-in page, set a new password from the token URL, then sign in with the new password.
What counts as working? #
| Flow | Expected |
|---|---|
| Request reset | From sign-in, open Forgot password?, submit Alice’s email, see the sent notice |
| Set new password | Open edit URL with a fresh token, save matching passwords, see reset notice |
| Sign in with new password | Alice signs in with new-password and sees Sign out |
Add scenarios to the test file #
Create a new test file with nano test/system/password_test.rb and add the following scenario:
# test/system/password_test.rb
require "application_system_test_case"
class PasswordTest < ApplicationSystemTestCase
# Actor: guest with Alice's account
# Starting point: sign-in page
# Action: Forgot password?, submit Alice's email, open edit URL with a fresh token, save matching passwords, sign in with new-password
# Expected outcome: reset-instructions notice, password-reset notice, Recipes page with Sign out
# test "resets password" do
# end
end
Add the system test #
Replace the test body for “resets password” with the following test cases:
# test/system/password_test.rb
require "application_system_test_case"
class PasswordTest < ApplicationSystemTestCase
test "resets password" do
user = users(:alice)
visit new_session_url
click_on "Forgot password?"
fill_in "Enter your email address", with: user.email_address
click_on "Email reset instructions"
assert_text /reset instructions sent/i
visit edit_password_url(user.password_reset_token)
fill_in "Enter new password", with: "new-password"
fill_in "Repeat new password", with: "new-password"
click_on "Save"
assert_text /Password has been reset/i
fill_in "Enter your email address", with: user.email_address
fill_in "Enter your password", with: "new-password"
click_on "Sign in"
assert_selector "h1", text: "Recipes"
assert_text "Sign out"
end
end
This is what’s happening in the code above:
- Test fills in the detail and clicks through buttons in the password reset flow (
Enter your email address,Enter new password,Repeat new password). - You use
visit edit_password_url(user.password_reset_token)to visit the edit password page with a fresh token instead of clicking the mailer link. The integration tests already proved enqueue behavior of mailer for the known email so you don’t need to test that again.
Run the test, you want 0 failures and 0 errors.
bin/rails test test/system/password_test.rb
Delete the scaffold Recipes controller test #
Chapter 5 left test/controllers/recipes_controller_test.rb in place after you ran it once. I had left it alone thinking it wouldn’t pose an issue in future but now with the introduction of Authentication, it breaks that file. Write actions still call get / post / patch / delete with no session, so they redirect to sign-in instead of changing recipes.
Run the controller test once:
bin/rails test test/controllers/recipes_controller_test.rb
You should see failures like this:
Failure:
RecipesControllerTest#test_should_destroy_recipe [test/controllers/recipes_controller_test.rb:42]:
`Recipe.count` didn't change by -1, but by 0.
You are not going to grow that scaffold file. Guest denials, signed-in writes, and HTML outcomes already live in test/integration/recipes_integration_test.rb and test/integration/recipe_access_integration_test.rb. Delete the drifted controller test instead of fixing the tests by sprinkling sign_in_as into generator-shaped tests:
rm test/controllers/recipes_controller_test.rb
Deleting the authentication generator’s own sessions_controller_test.rb and passwords_controller_test.rb stays optional if you still have them.
Commit your work #
Run the full suite:
bin/rails test:all
You want 0 failures and 0 errors across model, integration, and system tests. Then commit changes:
git add .
git commit -m "Add authentication, guest access, and password reset tests"
Small commits make it easier to roll back, bisect a regression, or pick up on another machine.
Want registration tests too? #
Rails generate authentication does not ship a sign-up form. This chapter stays on sessions and guest read-only access. When you add registration yourself and want Minitest coverage for it, see How to Test User Registration with Minitest Rails.
What is next #
In this chapter any signed-in user can edit or destroy a recipe. Chapter 13 introduces authorization concepts and locks edit/destroy to the owner while signed-in users can still create and guests can still read recipes.
Continue to Authorization testing.
Keep Minitest Rails independent
Minitest Rails is an independent educational guide for Rails developers learning automated testing.
Reader support funds new chapters, Rails version updates, and more real-world examples.
Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.