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 #

  1. Run bin/rails generate authentication, review the generated files, then migrate new tables.
  2. Add a fixture file for user and add Alice as a user with a password.
  3. Add a current_user helper on the Authentication concern.
  4. Sign in: skim generated controller code, write integration coverage, then red/green the sign-in system smoke.
  5. Review generated session helper for integration and add a new session helper for system tests.
  6. Sign out: integration and system tests using sign_in_* helpers.
  7. Guest read-only access: write denials, allow index/show pages to guests, then prove signed-in users see New, Edit, Destroy, and Remove buttons.
  8. TDD recipe create, update, and destroy tests (integration, then system): fail without a session (red), then sign in and go green.
  9. Password reset: skim generated controller, then integration and system coverage.
  10. Delete the scaffold recipes_controller_test.rb (it drifts under auth; integration already owns Recipes HTTP).
  11. Run bin/rails test:all to 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_user exposes this helper method to views and controllers.
  • current_user returns nil with no session. With a session it memoizes Current.user once 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 the new and create actions, allowing guests to reach the sign-in form and submit it. allow_unauthenticated_access comes from the Authentication concern at app/controllers/concerns/authentication.rb.
  • rate_limit caps sign-in attempts, then redirects back to sign-in with an alert. This is a security measure to prevent brute force attacks.
  • new renders the sign-in form.
  • create authenticates with User.authenticate_by, starts a session, and redirects to after_authentication_url (defaults to root_url). If the email or password is incorrect, it redirects back to the sign-in page with an alert.
  • destroy terminates 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 in opens the form, posts Alice’s fixture email and the shared test password, then follows the redirect to root_url.
  • rejects invalid password posts a bad password and expects a redirect back to new_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 the Authentication concern 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:

  1. By driving the sign-in form on every request.
  2. By setting the signed session_id directly 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 real Session row 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_out destroys the current session and deletes the cookie.
  • ActiveSupport.on_load includes the module on every ActionDispatch::IntegrationTest, so session and recipe tests can call sign_in_as without having to include SessionTestHelper in 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 real Session row, visits the sign-in page so the browser has a host, then injects the signed session_id cookie to the browser.
  • teardown { Capybara.reset_sessions! } clears browser cookies between tests so a leftover session does not leak.
  • The method lives in ApplicationSystemTestCase which 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_url hits SessionsController#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_url visits the recipes list page.
  • click_on "Sign out" clicks the Sign out button.
  • assert_current_path new_session_path checks 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:

  1. Start bin/dev if the server is not running.
  2. Visit http://localhost:3000/session/new (or click the Sign in link in the page).
  3. Sign in as Alice with email alice@example.com and password password.
  4. Confirm you land on the recipes list, see fixture titles, and see Sign out button.
  5. 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 keeps original_title and asserts recipe.reload.title instead.

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: 0 asserts in recipes_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:

  1. Start bin/dev if the server is not running.
  2. Visit http://localhost:3000/recipes while signed out. Confirm recipe titles appear and buttons for New recipe and Destroy this recipe are gone.
  3. Open Fluffy pancakes. Confirm the title appears and Edit this recipe, Destroy this recipe, and Remove are gone.
  4. Sign in as Alice with email alice@example.com and password password. 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 before get, post, patch, or delete.
  • 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_access allows 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 new renders the new password reset form.
  • def create finds the user by email address and sends a password reset email if the user exists.
  • def edit renders the edit password form.
  • def update updates 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_token builds 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.