Setup 🛠 Rails 8 App – Part 17: Fix Github actions, Mini-test 🗜️ Test Cases With Cursor AI ➘

Hello friends, in this post lets look into the issues we faced after integrating:

  1. Sessions for users (sign in functionality)
  2. Order, Order items models and the related code base

Check our Github actions failure cases here: https://github.com/MIRA-Designs/design_studio/actions/runs/14953772366/job/42006659413?pr=31

What is working?

  • Rubocop linting checks
  • Test cases – Fails
  • Brakeman security scan – Pending

Why the issue?

I made a mistake of integrating the sessions with Order and Order items model (I used the help of Co-piolet for improvement). I have only written the test cases for sessions. Why I made this mistake? Because for last some days the development was not happening in our design_studio project. So I was curious to do so many things at a time and I cannot control myself 🙂

Now what’s next?

Let’s solve the test cases issues. First I cannot get ‘why this test cases for Rate limit issue is happening’. I have to research on this. I want to try Cursor – how it works with these kind of scenarios. Cursor provide me details about exact issue in our test cases and updated for me. I was happy that I saved some time.

Let’s see What is the findings of cursor:

The current test case has few issues:

  1. It’s testing for 11 attempts, but the rate limit is set to 10 attempts. We should test that the 11th attempt is rate limited, but also verify that the first 10 attempts work as expected.
  2. We should verify that the rate limiting is time-based (within 1 minute) by testing that after waiting, the rate limit resets.

Fix the test case:

Cursor updated the test case to properly test the rate limiting functionality. Here’s what the improved test does:

  1. First, it makes 10 attempts and verifies that each attempt gets the normal “invalid credentials” response
  2. Then it makes the 11th attempt and verifies that it gets rate limited with the “Try again later” message
  3. Finally, it uses Rails’ travel helper to move forward in time past the rate limit window (1 minute + 1 second) and verifies that the rate limit has been reset by making another attempt

This test now properly verifies:

  • The rate limit threshold (10 attempts)
  • The rate limit behavior (redirect with “Try again later” message)
  • The rate limit duration (1 minute)
  • The rate limit reset after the time window expires

The test is now more thorough and matches the actual implementation in the SessionsController.

Run the test case with the fix:

Failure:
SessionsControllerTest#test_should_rate_limit_login_attempts [test/controllers/sessions_controller_test.rb:34]:
--- expected
+++ actual
@@ -1 +1 @@
-"Try again later."
+"Try another email address or password."

the problem is that the rate limiting is happening at the controller level using rate_limit, but our test isn’t properly simulating real-world timing. Let me modify the test to ensure we’re making the requests within the 1-minute window and properly testing the rate limiting.

We fixed it and still the issues are not solved:

# Running:
F
Failure:
SessionsControllerTest#test_should_rate_limit_login_attempts [test/controllers/sessions_controller_test.rb:36]:
--- expected
+++ actual
@@ -1 +1 @@
-"Try again later."
+"Try another email address or password."

bin/rails test test/controllers/sessions_controller_test.rb:23

The issue may be with our SessionsController rate_limit directive. That is not working in test environment. I had this doubt. Let’s see what cursor tell us regarding this:

Looking at the SessionsController, the rate limiting is implemented using Rails’ rate_limit macro, but it seems the rate limiting might not be working as expected in the test environment. Let’s try to debug this by checking if we need to enable rate limiting in the test environment or if there’s a different approach needed.

Cursor suggested Rails cache setup and wipe out cache:

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
  setup do
    # Use memory store for rate limiting in tests
    Rails.cache = ActiveSupport::Cache::MemoryStore.new
  end

  teardown do
    Rails.cache.clear
  end

  ......

The test environment is configured to use :null_store for caching, which means rate limiting won’t work. Let’s modify the test environment configuration:

environments/test.rb:

- config.cache_store = :null_store
+ config.cache_store = :memory_store

Cursor made two important changes:

  1. Changed the test environment’s cache store from :null_store to :memory_store to ensure rate limiting works properly
  2. Added a consistent IP address in the test requests using 
    headers: { REMOTE_ADDR: “192.168.1.1” } 
    since rate limiting is typically done based on the client’s IP address

The test should now work correctly because:

  1. We have proper caching enabled in the test environment
  2. We’re using a consistent IP address for all requests, which is what rate limiting typically uses to track requests
  3. We’re still maintaining the time window with travel_to and travel
  4. The cache is cleared between tests to ensure a clean state

Try running the test again – it should now properly detect when the rate limit is exceeded on the 11th request.

Running 5 tests in a single process (parallelization threshold is 50)
Run options: --seed 55394

# Running:
.

Finished in 1.222473s, 0.8180 runs/s, 28.6305 assertions/s.
1 runs, 35 assertions, 0 failures, 0 errors, 0 skips
Coverage report generated for Unit Tests to /Users/abhilash/rails/design_studio/public/coverage.
Line Coverage: 21.37% (81 / 379)

Works!! Cursor – you beauty.

Let us see why the fix worked:

  1. The main issue was that the test environment was using :null_store for caching, which effectively disabled all caching. Since Rails’ rate limiting feature relies on caching to track request counts, it wasn’t working properly.
  2. By changing to :memory_store, we enabled proper caching in the test environment, allowing the rate limiting to track requests.
  3. Adding the consistent IP address (REMOTE_ADDR: “192.168.1.1”) in the test requests was also important because Rails’ rate limiting typically uses the client’s IP address as part of the cache key to track requests per client.
  4. The test structure ensures clean state by:
  • Using setup to initialize a fresh cache store for each test
  • Using teardown to clear the cache after each test
  • Using travel_to to ensure consistent timing for rate limit windows
  • Using consistent IP addresses for request tracking

This is a good example of how testing rate limiting requires careful consideration of the entire environment, including caching, timing, and request attributes. The test now properly verifies that:

  • Users can make up to 10 attempts within a minute
  • The 11th attempt gets rate limited
  • After waiting for a minute, they can try again

I may need some more time to get the fact that: “Rails’ rate limiting feature relies on caching to track request counts”. Here is the importance of AI tool like cursor. It saved a lot of my debugging time.

Guide: Rails 8 API Application – Authentication 🔐 mechanisms | Sample Rails API app with Rspec Test cases

When building a Rails API app, you typically need token-based authentication instead of cookie-based sessions (which are more common in full-stack Rails apps). Here are the most common authentication mechanisms you can use in a Rails API-only application:

🔐 1. Token-Based Authentication

Most Common & Recommended for APIs

a. JWT (JSON Web Tokens)

  • Gems: jwt, knock, devise-jwt
  • How it works: After login, the server issues a JWT token which the client must include in the Authorization header (Bearer <token>) in subsequent requests.
  • Pros:
    • Stateless, scalable.
    • Widely supported across mobile and frontend frameworks.
  • Cons:
    • Tokens can’t be invalidated easily without extra measures (e.g., a blacklist).

b. Token-based Auth with Devise + TokenAuthenticatable

  • Gems: devise_token_auth
  • Uses Devise under the hood.
  • Stores tokens on the server (in DB), enabling logout and token revocation.
  • Compatible with React Native and SPAs.

🔐 2. OAuth 2.0 / OmniAuth (for Third-party Logins)

  • Gems: omniauth, doorkeeper
  • Use when you want users to log in via:
    • Google
    • Facebook
    • GitHub
  • Doorkeeper is often used to implement OAuth 2 provider (if you’re exposing your API to other apps).
  • Best when integrating external identity providers.

🔐 3. API Key Authentication

  • Useful for machine-to-machine communication or when exposing APIs to third-party developers.
  • Each user/client is assigned a unique API key.
  • Example: Authorization: Token token=abc123
  • You store the API key in the DB and verify it on each request.
  • Lightweight and easy to implement.

🔐 4. HTTP Basic Authentication

  • Simple and built-in with Rails (authenticate_or_request_with_http_basic).
  • Not suitable for production unless combined with HTTPS and only used for internal/testing tools.

👉🏻 Choosing the Right Auth Mechanism

Use CaseRecommended Method
Mobile app or frontend SPAJWT (devise-jwt / knock)
Internal API between servicesAPI key
Want email/password with token authdevise_token_auth
External login via Google/GitHubomniauth + doorkeeper
OAuth2 provider for third-party devsdoorkeeper
Quick-and-dirty internal authHTTP Basic Auth

🔄 How JWT Authentication Works — Step by Step

1. User Logs In

  • The client (e.g., React app, mobile app) sends a POST /login request with email/password.
  • Your Rails API validates the credentials.
  • If valid, it generates a JWT token and sends it back to the client.
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

2. Client Stores the Token

  • The client stores the token in localStorage, sessionStorage, or memory (for SPAs), or a secure storage for mobile apps.

3. Client Sends Token on Requests

  • For any subsequent request to protected resources, the client includes the JWT in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

4. Server Verifies the Token

  • Rails extracts the token, decodes it using a secret key, and verifies:
    • The signature is valid.
    • The token is not expired.
    • The user ID (or sub claim) is valid.

If everything checks out, the request is allowed to proceed.

5. Token Expiration

  • Tokens usually include an exp (expiration) claim, e.g., 15 minutes, 1 hour, etc.
  • After expiration, the client must log in again or use a refresh token flow if supported.

🔒 Security: Is JWT Secure?

JWT can be secure, if used correctly. Here’s a breakdown:

✅ Security Benefits

FeatureWhy It Helps
StatelessNo session storage needed; scales easily
SignedThe token is signed (HMAC or RSA), so it can’t be tampered with
CompactSent in headers; easy to pass around
Exp claimTokens expire automatically after a period

⚠️ Security Considerations

IssueDescriptionMitigation
Token theftIf an attacker steals the token, they can impersonate the user.Always use HTTPS. Avoid storing tokens in localStorage if possible.
No server-side revocationTokens can’t be invalidated until they expire.Use short-lived access tokens + refresh tokens or token blacklist (DB).
Long token lifespanLonger expiry means higher risk if leaked.Keep exp short (e.g., 15–30 min). Use refresh tokens if needed.
Poor secret handlingIf your secret key leaks, anyone can forge tokens.Store your JWT_SECRET in environment variables, never in code.
JWT stored in localStorageSusceptible to XSS attacks in web apps.Use HttpOnly cookies when possible, or protect against XSS.
Algorithm confusionAttacker could force a weak algorithm.Always validate the algorithm (alg) on decoding. Use only HMAC or RSA.

🧪 Example Token (Decoded)

A typical JWT has three parts:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDAwMDAwMDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Breakdown:

  1. Header (Base64-encoded JSON)
{
  "alg": "HS256",
  "typ": "JWT"
}

  1. Payload
{
  "user_id": 1,
  "exp": 1700000000
}

  1. Signature
  • HMAC-SHA256 hash of header + payload + secret key.

🛡 Best Practices for JWT in Rails API

  • Use devise-jwt or knock to handle encoding/decoding securely.
  • Set short token lifetimes (exp claim).
  • Use HTTPS only.
  • Consider implementing refresh tokens for session continuation.
  • Avoid token storage in localStorage unless you trust your frontend.
  • Rotate secrets periodically (invalidate tokens when secrets change).

Now Let’s create a sample Rails API application and test what we learned.

🧱 Sample Rails API web app: Prerequisites

  • A Rails 8 app with --api mode enabled: rails new my_api_app --api
  • A User model with email and password_digest.
  • We’ll use bcrypt for password hashing.

✅ Step 1: Add Required Gems

In your Gemfile:

gem 'jwt'
gem 'bcrypt'

Then run:

bundle install

✅ Step 2: Generate the User Model

rails g model User email:string password_digest:string
rails db:migrate

In app/models/user.rb:

class User < ApplicationRecord
  has_secure_password
end

Now you can create users with secure passwords.

✅ Step 3: Create JWT Helper Module

Create a service object or helper to encode/decode tokens.

app/lib/json_web_token.rb (create the lib folder if needed):

# app/lib/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY)
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY)[0]
    HashWithIndifferentAccess.new(decoded)
  rescue JWT::DecodeError => e
    nil
  end
end

✅ Step 4: Create the Authentication Controller

rails g controller auth

app/controllers/auth_controller.rb:

class AuthController < ApplicationController
  def login
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      token = JsonWebToken.encode(user_id: user.id)
      render json: { token: token }, status: :ok
    else
      render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end
end

✅ Step 5: Protect Other Endpoints with Authentication

Make a reusable authenticate_request method.

app/controllers/application_controller.rb:

class ApplicationController < ActionController::API
  before_action :authenticate_request

  attr_reader :current_user

  private

  def authenticate_request
    header = request.headers['Authorization']
    token = header.split(' ').last if header.present?

    if token
      decoded = JsonWebToken.decode(token)
      @current_user = User.find_by(id: decoded[:user_id]) if decoded
    end

    render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
  end
end

Now all your controllers inherit this behaviour unless you skip_before_action.

✅ Step 6: Add Routes

config/routes.rb:

Rails.application.routes.draw do
  post '/login', to: 'auth#login'

  get '/profile', to: 'users#profile' # Example protected route
end

✅ Step 7: Example Protected Controller

rails g controller users

app/controllers/users_controller.rb:

class UsersController < ApplicationController
  def profile
    render json: { id: current_user.id, email: current_user.email }
  end
end

🧪 Test It Out (Example)

Step 1: Create a User (via Rails Console)

User.create!(email: "test@example.com", password: "password123")

Step 2: Login via POST /login

POST /login
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "password123"
}

Response:

{ "token": "eyJhbGciOi..." }

Step 3: Use Token in Authenticated Request

GET /profile
Authorization: Bearer eyJhbGciOi...

🔒 Extras You Might Add Later

  • Token expiration errors
  • Refresh tokens
  • Token revocation (e.g., a blacklist table)
  • Roles/permissions inside the token (e.g., admin claims)

Let’s now write RSpec tests for the JWT-based authentication flow we just set up in your Rails API app.

Assumptions

  • You already have:
    • A User model with email and password_digest
    • An AuthController with login
    • A UsersController with a protected profile action
    • JWT auth logic in JsonWebToken

🔧 Step 1: Add RSpec & Factory Bot

In your Gemfile (if not already added):

group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end

group :test do
  gem 'faker'
end

Then install:

bundle install
rails generate rspec:install


🏭 Step 2: Setup Factory for User

spec/factories/users.rb:

FactoryBot.define do
  factory :user do
    email { Faker::Internet.email }
    password { 'password123' }
    password_confirmation { 'password123' }
  end
end


🧪 Step 3: Auth Request Specs

spec/requests/auth_spec.rb:

require 'rails_helper'

RSpec.describe 'Authentication', type: :request do
  let!(:user) { create(:user, password: 'password123') }

  describe 'POST /login' do
    context 'with valid credentials' do
      it 'returns a JWT token' do
        post '/login', params: { email: user.email, password: 'password123' }

        expect(response).to have_http_status(:ok)
        expect(JSON.parse(response.body)).to include('token')
      end
    end

    context 'with invalid credentials' do
      it 'returns unauthorized' do
        post '/login', params: { email: user.email, password: 'wrong' }

        expect(response).to have_http_status(:unauthorized)
        expect(JSON.parse(response.body)).to include('error')
      end
    end
  end
end


🔒 Step 4: Profile (Protected) Request Specs

spec/requests/users_spec.rb:

require 'rails_helper'

RSpec.describe 'Users', type: :request do
  let!(:user) { create(:user) }
  let(:token) { JsonWebToken.encode(user_id: user.id) }

  describe 'GET /profile' do
    context 'with valid token' do
      it 'returns user profile' do
        get '/profile', headers: { 'Authorization' => "Bearer #{token}" }

        expect(response).to have_http_status(:ok)
        json = JSON.parse(response.body)
        expect(json['email']).to eq(user.email)
      end
    end

    context 'without token' do
      it 'returns unauthorized' do
        get '/profile'
        expect(response).to have_http_status(:unauthorized)
      end
    end

    context 'with invalid token' do
      it 'returns unauthorized' do
        get '/profile', headers: { 'Authorization' => 'Bearer invalid.token' }
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
end

📦 Final Tips

  • Run tests with: bundle exec rspec
  • You can stub JsonWebToken.decode in unit tests if needed to isolate auth logic.


📕 Guide: Mini-test 🧪 VS Rspec 🔬 in Rails Applications

When choosing between RSpec and Minitest for writing tests in a Ruby on Rails application, both are solid options, but the best choice depends on your project goals, team preferences, and ecosystem alignment.

♦️ Use RSpec if:

  • You want a rich DSL for expressive, readable tests (describe, context, it, etc.).
  • You’re working on a large project or with a team familiar with RSpec.
  • You want access to a larger ecosystem of gems/plugins (e.g., FactoryBot, Shoulda Matchers).
  • You like writing spec-style tests and separating tests by type (spec/models, spec/controllers, etc.).

Example RSpec syntax:

describe User do
  it "is valid with a name and email" do
    user = User.new(name: "Alice", email: "alice@example.com")
    expect(user).to be_valid
  end
end


♦️ Use Minitest if:

  • You prefer simplicity and speed — it’s built into Rails and requires no setup.
  • You value convention over configuration and a more Ruby-like test style.
  • You’re working on a small-to-medium project or want to avoid extra dependencies.
  • You like tests integrated with rails test without RSpec’s additional structure.

Example Minitest syntax:

class UserTest < ActiveSupport::TestCase
  test "is valid with a name and email" do
    user = User.new(name: "Alice", email: "alice@example.com")
    assert user.valid?
  end
end


🚦Recommendation:

  • Go with RSpec if you want a full-featured testing suite, lots of documentation, and are okay with learning a custom DSL.
  • Stick with Minitest if you want fast boot time, minimal dependencies, and simpler syntax.

Below is a side-by-side comparison of RSpec and Minitest in a Rails 8 context. For each aspect—setup, syntax, assertions, fixtures/factories, controller tests, etc.—you’ll see how you’d do the same thing in RSpec (left) versus Minitest (right). Wherever possible, the examples mirror each other so you can quickly spot the differences.


1. Setup & Configuration

AspectRSpecMinitest
Gem inclusionAdd to your Gemfile:
ruby<br>group :development, :test do<br> gem 'rspec-rails', '~> 6.0' # compatible with Rails 8<br>end<br>Then run:bash<br>bundle install<br>rails generate rspec:install<br>This creates spec/ directory with spec/spec_helper.rb and spec/rails_helper.rb.
Built into Rails. No extra gems required. When you generate your app, Rails already configures Minitest.By default you have test/ directory with test/test_helper.rb.

2. Folder Structure

TypeRSpecMinitest
Model specs/testsspec/models/user_spec.rbtest/models/user_test.rb
Controller specs/testsspec/controllers/users_controller_spec.rbtest/controllers/users_controller_test.rb
Request specs/testsspec/requests/api/v1/users_spec.rb (or spec/requests/…)test/integration/api/v1/users_test.rb
Fixture/Factory filesspec/factories/*.rb (with FactoryBot or similar)test/fixtures/*.yml
Helper filesspec/support/... (you can require them via rails_helper.rb)test/helpers/... (auto-loaded via test_helper.rb)

3. Basic Model Validation Example

RSpec (spec/models/user_spec.rb)

# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  context "validations" do
    it "is valid with a name and email" do
      user = User.new(name: "Alice", email: "alice@example.com")
      expect(user).to be_valid
    end

    it "is invalid without an email" do
      user = User.new(name: "Alice", email: nil)
      expect(user).not_to be_valid
      expect(user.errors[:email]).to include("can't be blank")
    end
  end
end

Minitest (test/models/user_test.rb)

# test/models/user_test.rb
require "test_helper"

class UserTest < ActiveSupport::TestCase
  test "valid with a name and email" do
    user = User.new(name: "Alice", email: "alice@example.com")
    assert user.valid?
  end

  test "invalid without an email" do
    user = User.new(name: "Alice", email: nil)
    refute user.valid?
    assert_includes user.errors[:email], "can't be blank"
  end
end


4. Using Fixtures vs. Factories

RSpec (with FactoryBot)

  1. Gemfile: group :development, :test do gem 'rspec-rails', '~> 6.0' gem 'factory_bot_rails' end
  2. Factory definition (spec/factories/users.rb): # spec/factories/users.rb FactoryBot.define do factory :user do name { "Bob" } email { "bob@example.com" } end end
  3. Spec using factory: # spec/models/user_spec.rb require 'rails_helper' RSpec.describe User, type: :model do it "creates a valid user via factory" do user = FactoryBot.build(:user) expect(user).to be_valid end end

Minitest (with Fixtures or Minitest Factories)

  1. Default fixture (test/fixtures/users.yml):
    alice: name: Alice email: alice@example.com bob: name: Bob email: bob@example.com
  2. Test using fixture:
    # test/models/user_test.rb
    require "test_helper"
    class UserTest < ActiveSupport::TestCase
    test "fixture user is valid" do
    user = users(:alice) assert user.valid?
    end
    end
  3. (Optional) Using minitest-factory_bot:
    If you prefer factory style, you can add gem 'minitest-factory_bot', define factories similarly under test/factories, and then: # test/models/user_test.rb require "test_helper" class UserTest < ActiveSupport::TestCase include FactoryBot::Syntax::Methods test "factory user is valid" do user = build(:user) assert user.valid? end end

5. Assertions vs. Expectations

CategoryRSpec (expectations)Minitest (assertions)
Check truthinessexpect(some_value).to be_truthyassert some_value
Check false/nilexpect(value).to be_falseyrefute value
Equalityexpect(actual).to eq(expected)assert_equal expected, actual
Inclusionexpect(array).to include(item)assert_includes array, item
Change/Count differenceexpect { action }.to change(Model, :count).by(1)assert_difference 'Model.count', 1 do <br> action<br>end
Exception raisedexpect { code }.to raise_error(ActiveRecord::RecordNotFound)assert_raises ActiveRecord::RecordNotFound do<br> code<br>end

Example: Testing a Creation Callback

RSpec:

# spec/models/post_spec.rb
require 'rails_helper'

RSpec.describe Post, type: :model do
  it "increments Post.count by 1 when created" do
    expect { Post.create!(title: "Hello", content: "World") }
      .to change(Post, :count).by(1)
  end
end

Minitest:

# test/models/post_test.rb
require "test_helper"

class PostTest < ActiveSupport::TestCase
  test "creation increases Post.count by 1" do
    assert_difference 'Post.count', 1 do
      Post.create!(title: "Hello", content: "World")
    end
  end
end


6. Controller (Request/Integration) Tests

6.1 Controller‐Level Test

RSpec (spec/controllers/users_controller_spec.rb)

# spec/controllers/users_controller_spec.rb
require 'rails_helper'

RSpec.describe UsersController, type: :controller do
  let!(:user) { FactoryBot.create(:user) }

  describe "GET #show" do
    it "returns http success" do
      get :show, params: { id: user.id }
      expect(response).to have_http_status(:success)
    end

    it "assigns @user" do
      get :show, params: { id: user.id }
      expect(assigns(:user)).to eq(user)
    end
  end

  describe "POST #create" do
    context "with valid params" do
      let(:valid_params) { { user: { name: "Charlie", email: "charlie@example.com" } } }

      it "creates a new user" do
        expect {
          post :create, params: valid_params
        }.to change(User, :count).by(1)
      end

      it "redirects to user path" do
        post :create, params: valid_params
        expect(response).to redirect_to(user_path(User.last))
      end
    end

    context "with invalid params" do
      let(:invalid_params) { { user: { name: "", email: "" } } }

      it "renders new template" do
        post :create, params: invalid_params
        expect(response).to render_template(:new)
      end
    end
  end
end

Minitest (test/controllers/users_controller_test.rb)
# test/controllers/users_controller_test.rb
require "test_helper"

class UsersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:alice)  # from fixtures
  end

  test "should get show" do
    get user_url(@user)
    assert_response :success
    assert_not_nil assigns(:user)   # note: assigns may need enabling in Rails 8
  end

  test "should create user with valid params" do
    assert_difference 'User.count', 1 do
      post users_url, params: { user: { name: "Charlie", email: "charlie@example.com" } }
    end
    assert_redirected_to user_url(User.last)
  end

  test "should render new for invalid params" do
    post users_url, params: { user: { name: "", email: "" } }
    assert_response :success        # renders :new with 200 status by default
    assert_template :new
  end
end

Note:

  • In Rails 8, controller tests are typically integration tests (ActionDispatch::IntegrationTest) rather than old‐style unit tests. RSpec’s type: :controller still works, but you can also use type: :request (see next section).
  • assigns(...) is disabled by default in modern Rails controller tests. In Minitest, you might enable it or test via response body or JSON instead.

6.2 Request/Integration Test

RSpec Request Spec (spec/requests/users_spec.rb)
# spec/requests/users_spec.rb
require 'rails_helper'

RSpec.describe "Users API", type: :request do
  let!(:user) { FactoryBot.create(:user) }

  describe "GET /api/v1/users/:id" do
    it "returns the user in JSON" do
      get api_v1_user_path(user), as: :json
      expect(response).to have_http_status(:ok)
      json = JSON.parse(response.body)
      expect(json["id"]).to eq(user.id)
      expect(json["email"]).to eq(user.email)
    end
  end

  describe "POST /api/v1/users" do
    let(:valid_params) { { user: { name: "Dana", email: "dana@example.com" } } }

    it "creates a user" do
      expect {
        post api_v1_users_path, params: valid_params, as: :json
      }.to change(User, :count).by(1)
      expect(response).to have_http_status(:created)
    end
  end
end

Minitest Integration Test (test/integration/users_api_test.rb)
# test/integration/users_api_test.rb
require "test_helper"

class UsersApiTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:alice)
  end

  test "GET /api/v1/users/:id returns JSON" do
    get api_v1_user_path(@user), as: :json
    assert_response :success
    json = JSON.parse(response.body)
    assert_equal @user.id, json["id"]
    assert_equal @user.email, json["email"]
  end

  test "POST /api/v1/users creates a user" do
    assert_difference 'User.count', 1 do
      post api_v1_users_path, params: { user: { name: "Dana", email: "dana@example.com" } }, as: :json
    end
    assert_response :created
  end
end


7. Testing Helpers, Mailers, and Jobs

Test TypeRSpec ExampleMinitest Example
Helper Specspec/helpers/application_helper_spec.rbruby<br>describe ApplicationHelper do<br> describe "#formatted_date" do<br> it "formats correctly" do<br> expect(helper.formatted_date(Date.new(2025,1,1))).to eq("January 1, 2025")<br> end<br> end<br>endtest/helpers/application_helper_test.rbruby<br>class ApplicationHelperTest < ActionView::TestCase<br> test "formatted_date outputs correct format" do<br> assert_equal "January 1, 2025", formatted_date(Date.new(2025,1,1))<br> end<br>end
Mailer Specspec/mailers/user_mailer_spec.rbruby<br>describe UserMailer, type: :mailer do<br> describe "#welcome_email" do<br> let(:user) { create(:user, email: "test@example.com") }<br> let(:mail) { UserMailer.welcome_email(user) }<br> it "renders subject" do<br> expect(mail.subject).to eq("Welcome!")<br> end<br> it "sends to correct recipient" do<br> expect(mail.to).to eq([user.email])<br> end<br> end<br>endtest/mailers/user_mailer_test.rbruby<br>class UserMailerTest < ActionMailer::TestCase<br> test "welcome email" do<br> user = users(:alice)<br> mail = UserMailer.welcome_email(user)<br> assert_equal "Welcome!", mail.subject<br> assert_equal [user.email], mail.to<br> assert_match "Hello, #{user.name}", mail.body.encoded<br> end<br>end
Job Specspec/jobs/process_data_job_spec.rbruby<br>describe ProcessDataJob, type: :job do<br> it "queues the job" do<br> expect { ProcessDataJob.perform_later(123) }.to have_enqueued_job(ProcessDataJob).with(123)<br> end<br>endtest/jobs/process_data_job_test.rbruby<br>class ProcessDataJobTest < ActiveJob::TestCase<br> test "job is enqueued" do<br> assert_enqueued_with(job: ProcessDataJob, args: [123]) do<br> ProcessDataJob.perform_later(123)<br> end<br> end<br>end

8. Mocking & Stubbing

TechniqueRSpecMinitest
Stubbing a methodruby<br>allow(User).to receive(:send_newsletter).and_return(true)<br>ruby<br>User.stub(:send_newsletter, true) do<br> # ...<br>end<br>
Mocking an objectruby<br>mailer = double("Mailer")<br>expect(mailer).to receive(:deliver).once<br>allow(UserMailer).to receive(:welcome).and_return(mailer)<br>ruby<br>mailer = Minitest::Mock.new<br>mailer.expect :deliver, true<br>UserMailer.stub :welcome, mailer do<br> # ...<br>end<br>mailer.verify<br>

9. Test Performance & Boot Time

  • RSpec
    • Slower boot time because it loads extra files (rails_helper.rb, support files, matchers).
    • Rich DSL can make tests slightly slower, but you get clearer, more descriptive output.
  • Minitest
    • Faster boot time since it’s built into Rails and has fewer abstractions.
    • Ideal for a smaller codebase or when you want minimal overhead.

Benchmarks:
While exact numbers vary, many Rails 8 teams report ~20–30% faster test suite runtime on Minitest vs. RSpec for comparable test counts. If speed is critical and test suite size is moderate, Minitest edges out.


10. Community, Ecosystem & Plugins

FeatureRSpecMinitest
PopularityBy far the most popular Rails testing framework⸺heavily used, many tutorials.Standard in Rails. Fewer third-party plugins than RSpec, but has essential ones (e.g., minitest-rails, minitest-factory_bot).
Common plugins/gems• FactoryBot• Shoulda Matchers (for concise model validations)• Database Cleaner (though Rails 8 encourages use_transactional_tests)• Capybara built-in support• minitest-rails-capybara (for integration/feature specs)• minitest-reporters (improved output)• minitest-factory_bot
Learning curveLarger DSL to learn (e.g., describe, context, before/let/subject, custom matchers).Minimal DSL—familiar Ruby methods (assert, refute, etc.).
Documentation & tutorialsAbundant (RSPEC official guides, many blog posts, StackOverflow).Good coverage in Rails guides; fewer dedicated tutorials but easy to pick up if you know Ruby.
CI IntegrationExcellent support in CircleCI, GitHub Actions, etc. Many community scripts to parallelize RSpec.Equally easy to integrate; often faster out of the box due to fewer dependencies.

11. Example: Complex Query Test (Integration of AR + Custom Validation)

RSpec

# spec/models/order_spec.rb
require 'rails_helper'

RSpec.describe Order, type: :model do
  describe "scopes and validations" do
    before do
      @user       = FactoryBot.create(:user)
      @valid_attrs = { user: @user, total_cents: 1000, status: "pending" }
    end

    it "finds only completed orders" do
      FactoryBot.create(:order, user: @user, status: "completed")
      FactoryBot.create(:order, user: @user, status: "pending")
      expect(Order.completed.count).to eq(1)
    end

    it "validates total_cents is positive" do
      order = Order.new(@valid_attrs.merge(total_cents: -5))
      expect(order).not_to be_valid
      expect(order.errors[:total_cents]).to include("must be greater than or equal to 0")
    end
  end
end

Minitest

# test/models/order_test.rb
require "test_helper"

class OrderTest < ActiveSupport::TestCase
  setup do
    @user = users(:alice)
    @valid_attrs = { user: @user, total_cents: 1000, status: "pending" }
  end

  test "scope .completed returns only completed orders" do
    Order.create!(@valid_attrs.merge(status: "completed"))
    Order.create!(@valid_attrs.merge(status: "pending"))
    assert_equal 1, Order.completed.count
  end

  test "validates total_cents is positive" do
    order = Order.new(@valid_attrs.merge(total_cents: -5))
    refute order.valid?
    assert_includes order.errors[:total_cents], "must be greater than or equal to 0"
  end
end


12. When to Choose Which?

  • Choose RSpec if …
    1. You want expressive, English-like test descriptions (describe, context, it).
    2. Your team is already comfortable with RSpec.
    3. You need a large ecosystem of matchers/plugins (e.g., shoulda-matchers, faker, etc.).
    4. You prefer separating specs into spec/ with custom configurations in rails_helper.rb and spec_helper.rb.
  • Choose Minitest if …
    1. You want zero additional dependencies—everything is built into Rails.
    2. You value minimal configuration and convention over configuration.
    3. You need faster test suite startup and execution.
    4. Your tests are simple enough that a minimal DSL is sufficient.

13. 📋 Summary Table

FeatureRSpecMinitest
Built-in with RailsNo (extra gem)Yes
DSL Readability“describe/context/it” blocks → very readablePlain Ruby test classes & methods → idiomatic but less English-like
Ecosystem & PluginsVery rich (FactoryBot, Shoulda, etc.)Leaner, but you can add factories & reporters if needed
Setup/Boot TimeSlower (loads extra config & DSL)Faster (built-in)
Fixtures vs. Factory preferenceFactoryBot (by convention)Default YAML fixtures or optionally minitest-factory_bot
Integration Test SupportBuilt-in type: :requestBuilt-in ActionDispatch::IntegrationTest
Community AdoptionMore widely adopted for large Rails teamsStandard for many smaller Rails projects

✍️ Final Note

  • If you’re just starting out and want something up and running immediately—Minitest is the simplest path since it requires no extra gems. You can always add more complexity later (e.g., add minitest-factory_bot or minitest-reporters).
  • If you plan to write a lot of tests—model validations, request specs, feature specs, etc.—with very expressive descriptions (and you don’t mind a slightly longer boot time), RSpec tends to be the de facto choice in many Rails codebases.

Feel free to pick whichever aligns best with your team’s style. Both ecosystems are mature and well-documented.

A Complete Information About Ruby on Rails Gems 💎, Gemfile 📑

In a Rails Gemfile, the require: false option tells Bundler not to automatically load the gem when your Rails application starts. Here’s what it means and when to use it:

What It Does

gem 'some_gem', require: false
  • Without require: false: The gem is automatically required (loaded) when your Rails app boots
  • With require: false: The gem is installed but won’t be loaded until you explicitly require it

When to Use It

  1. Performance Optimization: For gems you don’t need in all environments (like development-only tools)
  2. Conditional Loading: When you only need a gem in specific circumstances
  3. Reduced Memory Usage: Avoids loading unnecessary gems into memory
  4. Avoid Naming Conflicts: If a gem might conflict with others when loaded

Example Usage

# Only load in development
group :development do
  gem 'brakeman', require: false
end

# Load manually when needed
gem 'nokogiri', require: false

# Then in your code:
def parse_xml
  require 'nokogiri'
  # use Nokogiri...
end

Common Gems That Use This

  • Testing tools (RSpec, Cucumber)
  • Performance monitoring tools
  • Debugging tools (byebug, pry)
  • Gems used only in rake tasks

Remember that without require: false, Bundler will automatically require the gem, which is the default behavior for most gems in your application.

to be continued.. 🚀


Deep Dive into Essential 🛍️ Ruby and Ruby on Rails Concepts

Ruby and Ruby on Rails are rich, expressive, and powerful technologies that make web development both elegant and productive. In this post, we’ll explore some critical concepts that developers often encounter, along with detailed explanations, advantages, disadvantages, and real-world Rails examples.


1. Garbage Collection (GC) in Ruby

Ruby’s VM uses a mark‑and‑sweep collector with generational enhancements to reduce pause times.

How it works

  1. Generational Division: Objects are split into young (eden/survivor) and old generations. Young objects are collected more frequently.
  2. Mark Phase: It traverses from root nodes (globals, stack, constants) marking reachable objects.
  3. Sweep Phase: Clears unmarked (garbage) objects.
  4. Compaction (in newer versions): Optionally compacts memory to reduce fragmentation.
# Trigger a minor GC (young generation)
GC.start(full_mark: false)
# Trigger a major GC (both generations)
GC.start(full_mark: true)

Benefits

  • Automatic memory management: Developers focus on logic, not free/delete calls.
  • Generational optimizations: Short‑lived objects reclaimed quickly, improving throughput.

Drawbacks

  • Pause times: Full GC can cause latency spikes.
  • Tuning complexity: Advanced apps may require tuning GC parameters (e.g., RUBY_GC_HEAP_GROWTH_FACTOR).

Rails Example

Large Rails apps (e.g., Sidekiq workers) monitor GC.stat to detect memory bloat:

stats = GC.stat
puts "Allocated objects: #{stats[:total_allocated_objects]}"


2. ActiveRecord: joins, preload, includes, eager_load

ActiveRecord provides tools to fetch associations efficiently and avoid the N+1 query problem.

MethodSQL GeneratedBehaviorProsCons
joinsINNER JOINFilters by associated tableEfficient filtering; single queryDoesn’t load associated objects fully
preload2 separate queriesLoads parent then child separatelyAvoids N+1; simple to useTwo queries; might fetch unnecessary data
includesJOIN or 2 queriesAuto‑decides between JOIN or preloadFlexible; avoids N+1 automaticallyHarder to predict SQL; can generate large JOINs
eager_loadLEFT OUTER JOINForces single JOIN queryAlways one query with dataLarge result sets; potential data duplication

Examples

# joins: Filter variants with women category products
> ProductVariant.joins(:product).where(product: {category: 'women'})
  ProductVariant Load (3.4ms)  SELECT "product_variants".* FROM "product_variants" INNER JOIN "products" "product" ON "product"."id" = "product_variants"."product_id" WHERE "product"."category" = 'women'

# preload: Load variants separately
> products = Product.preload(:variants).limit(10)
  Product Load (1.4ms)  SELECT "products".* FROM "products" /* loading for pp */ LIMIT 10 
  ProductVariant Load (0.5ms)  SELECT "product_variants".* FROM "product_variants" WHERE "product_variants"."product_id" IN (14, 15, 32)
> products.each { |product| product.variants.size}

# includes: Smart loading
products = > Product.includes(:variants).where("category = ?", 'women')
  Product Load (1.7ms)  SELECT "products".* FROM "products" WHERE (category = 'women') /* loading for pp */ LIMIT 11 
  ProductVariant Load (0.8ms)  SELECT "product_variants".* FROM "product_variants" WHERE "product_variants"."product_id" IN (14, 15)

# eager_load: Always join
Product.eager_load(:variants).where(variants: { stock_quantity: 5 })
> Product.eager_load(:variants).where(variants: { stock_quantity: 5 })
  SQL (3.1ms)  SELECT DISTINCT "products"."id" FROM "products" LEFT OUTER JOIN "product_variants" "variants" ON "variants"."product_id" = "products"."id" WHERE "variants"."stock_quantity" = 5 LIMIT 11 

  SQL (1.6ms)  SELECT "products"."id" AS t0_r0, "products"."description" AS t0_r1, "products"."category" AS t0_r2, "products"."created_at" AS t0_r3, "products"."updated_at" AS t0_r4, "products"."name" AS t0_r5, "products"."rating" AS t0_r6, "products"."brand" AS t0_r7, "variants"."id" AS t1_r0, "variants"."product_id" AS t1_r1, "variants"."sku" AS t1_r2, "variants"."mrp" AS t1_r3, "variants"."price" AS t1_r4, "variants"."discount_percent" AS t1_r5, "variants"."size" AS t1_r6, "variants"."color" AS t1_r7, "variants"."stock_quantity" AS t1_r8, "variants"."specs" AS t1_r9, "variants"."created_at" AS t1_r10, "variants"."updated_at" AS t1_r11 FROM "products" LEFT OUTER JOIN "product_variants" "variants" ON "variants"."product_id" = "products"."id" WHERE "variants"."stock_quantity" = 5 AND "products"."id" = 15 

When to Use

  • joins: Filtering, counting, or conditions across tables.
  • preload: You only need associated objects later, with less risk of huge joins.
  • includes: Default choice; let AR decide.
  • eager_load: Complex filtering on associations in one query.

3. Achieving Multiple Inheritance via Mixins

Ruby uses modules as mixins to simulate multiple inheritance.

Pattern

module Auditable
  def audit(message)
    puts "Audit: #{message}"
  end
end

module Taggable
  def tag(*names)
    @tags = names
  end
end

class Article
  include Auditable, Taggable
end

article = Article.new
tag "ruby", "rails"
audit "Created article"

Benefits

  • Code reuse: Share behavior across unrelated classes.
  • Separation of concerns: Each module encapsulates specific functionality.

Drawbacks

  • Method conflicts: Last included module wins; resolve with Module#prepend or alias_method.

Rails Example: Concerns

# app/models/concerns/trackable.rb
module Trackable
  extend ActiveSupport::Concern

  included do
    after_create :track_create
  end

  def track_create
    AnalyticsService.log(self)
  end
end

class User < ApplicationRecord
  include Trackable
end


4. Thread vs Fiber

Ruby offers preemptive threads and cooperative fibers for concurrency.

AspectThreadFiber
SchedulingOS-level, preemptiveRuby-level, manual (Fiber.yield/ resume)
OverheadHigher (context switch cost)Lower (lightweight)
Use CasesParallel I/O, CPU-bound (with GVL caveat)Managing event loops, non-blocking flows
GVL ImpactAll threads share GIL (Global VM Lock)Fibers don’t bypass GVL

Thread Example

threads = 5.times.map do
  Thread.new { sleep 1; puts "Done in thread #{Thread.current.object_id}" }
end
threads.each(&:join)

Fiber Example

fiber1 = Fiber.new do
  puts "Fiber1 start"
  Fiber.yield
  puts "Fiber1 resume"
end

fiber2 = Fiber.new do
  puts "Fiber2 start"
  fiber1.resume
  puts "Fiber2 resume"
end

fiber2.resume  # orchestrates both fibers

Rails Example: Action Cable

Action Cable uses EventMachine or async fibers to handle multiple WebSocket connections efficiently.


5. Proc vs Lambda

Both are callable objects, but differ in return behavior and argument checks.

FeatureProcLambda
Return semanticsreturn exits enclosing methodreturn exits lambda only
Argument checkingLenient (extra args discarded)Strict (ArgumentError on mismatch)
ContextCarries method contextMore like an anonymous method

Examples

def demo_proc
  p = Proc.new { return "from proc" }
  p.call
  return "after proc"
end

def demo_lambda
  l = -> { return "from lambda" }
  l.call
  return "after lambda"
end
puts demo_proc   # => "from proc"
puts demo_lambda # => "after lambda"

Rails Example: Callbacks

# Using a lambda for a conditional callback
class User < ApplicationRecord
  after_save -> { Analytics.track(self) }, if: -> { saved_change_to_email? }
end


6. Exception Handling in Ruby

Ruby’s exception model is dynamic and flexible.

Syntax

begin
  risky_operation
rescue SpecificError => e
  handle_error(e)
rescue AnotherError
  fallback
else
  puts "No errors"
ensure
  cleanup_resources
end

Benefits

  • Granular control: Multiple rescue clauses per exception class.
  • Flow control: rescue can be used inline (foo rescue nil).

Drawbacks

  • Performance: Raising/catching exceptions is costly.
  • Overuse: Rescuing StandardError broadly can hide bugs.

Rails Example: Custom Exceptions

class PaymentError < StandardError; end

def process_payment
  raise PaymentError, "Insufficient funds" unless valid_funds?
rescue PaymentError => e
  errors.add(:base, e.message)
end


7. Key Ruby on Rails Modules

Rails is modular, each gem serves a purpose:

ModulePurposeBenefits
ActiveRecordORM: models to DB tablesDRY queries, validations, callbacks
ActionControllerControllers: request/response cycleFilters, strong parameters
ActionViewView templates (ERB, Haml)Helpers, partials
ActiveModelModel conventions for non-DB classesValidations, callbacks without DB
ActiveJobJob framework (sidekiq, resque adapters)Unified API for background jobs
ActionMailerEmail composition & deliveryInterceptors, mailer previews
ActionCableWebSocket supportStreams, channels
ActiveStorageFile uploads & CDN integrationDirect uploads, variants
ActiveSupportUtility extensions (core extensions, inflections)Time calculations, i18n, concerns support

8. Method Visibility: public, protected, private

Visibility controls encapsulation and API design.

ModifierAccess FromUse Case
publicEverywherePublic API methods
privateSame instance onlyHelper methods not meant for external use
protectedInstances of same class or subclassesComparison or interaction between related objects
class Account
  def transfer(to, amount)
    validate_balance(amount)
    to.deposit(amount)
  end

  private

  def validate_balance(amount)
    raise "Insufficient" if balance < amount
  end

  protected

  def balance
    @balance
  end
end

Advantages

  • Encapsulation: Hides implementation details.
  • Inheritance control: Fine‑grained access for subclasses.

Disadvantages

  • Rigidity: Can complicate testing private methods.
  • Confusion: Protected rarely used, often misunderstood.
Above Summary

By diving deeper into these core concepts, you’ll gain a solid understanding of Ruby’s internals, ActiveRecord optimizations, module mixins, concurrency strategies, callable objects, exception patterns, Rails modules, and visibility controls. Practice these patterns in your own projects to fully internalize their benefits and trade‑offs.

Other Ruby on Rails Concepts 💡

Now, we’ll explore several foundational topics in Ruby on Rails, complete with detailed explanations, code examples, and a balanced look at advantages and drawbacks.

1. Rack and Middleware

Check our post: https://railsdrop.com/2025/04/07/inside-rails-the-role-of-rack-and-middleware/

What is Rack?
Rack is the Ruby interface between web servers (e.g., Puma, Unicorn) and Ruby web frameworks (Rails, Sinatra). It standardizes how HTTP requests and responses are handled, enabling middleware stacking and pluggable request processing.

Middleware
Rack middleware are modular components that sit in the request/response pipeline. Each piece can inspect, modify, or short-circuit requests before they reach your Rails app, and likewise inspect or modify responses before they go back to the client.

# lib/simple_logger.rb
class SimpleLogger
  def initialize(app)
    @app = app
  end

  def call(env)
    Rails.logger.info("[Request] #{env['REQUEST_METHOD']} #{env['PATH_INFO']}")
    status, headers, response = @app.call(env)
    Rails.logger.info("[Response] status=#{status}")
    [status, headers, response]
  end
end

# config/application.rb
config.middleware.use SimpleLogger

Benefits:

  • Cross-cutting concerns (logging, security, caching) can be isolated.
  • Easily inserted, removed, or reordered.

Drawbacks:

  • Overuse can complicate request flow.
  • Harder to trace when many middlewares are chained.

2. The N+1 Query Problem

What is N+1?
Occurs when Rails executes one query to load a collection, then an additional query for each record when accessing an association.

@users = User.all                # 1 query
@users.each { |u| u.posts.count } # N additional queries

Total: N+1 queries.

Prevention: use eager loading (includes, preload, eager_load).

@users = User.includes(:posts)
@users.each { |u| u.posts.count } # still 2 queries only

Benefits of Eager Loading:

  • Dramatically reduces SQL round-trips.
  • Improves response times for collections.

Drawbacks:

  • May load unnecessary data if associations aren’t used.
  • Can lead to large, complex SQL (especially with eager_load).

3. Using Concerns

What are Concerns?
Modules under app/models/concerns (or app/controllers/concerns) to extract and share reusable logic.

# app/models/concerns/archivable.rb
module Archivable
  extend ActiveSupport::Concern

  included do
    scope :archived, -> { where(archived: true) }
  end

  def archive!
    update!(archived: true)
  end
end

# app/models/post.rb
class Post < ApplicationRecord
  include Archivable
end

When to Extract:

  • Shared behavior across multiple models/controllers.
  • To keep classes focused and under ~200 lines.

Benefits:

  • Promotes DRY code.
  • Encourages separation of concerns.

Drawbacks:

  • Can mask complexity if overused.
  • Debugging call stacks may be less straightforward.

4. HABTM vs. Has Many Through

HABTM (has_and_belongs_to_many):

  • Simple many-to-many with a join table without a Rails model.
class Post < ApplicationRecord
  has_and_belongs_to_many :tags
end

Has Many Through:

  • Use when the join table has additional attributes or validations.
class Tagging < ApplicationRecord
  belongs_to :post
  belongs_to :tag
  validates :tagged_by, presence: true
end

class Post < ApplicationRecord
  has_many :taggings
  has_many :tags, through: :taggings
end

Benefits & Drawbacks:

PatternBenefitsDrawbacks
HABTMMinimal setup; fewer filesCannot store metadata on relationship
Has Many ThroughFull join model control; validationsMore boilerplate; extra join model to maintain

5. Controller Hooks (Callbacks)

Rails controllers provide before_action, after_action, and around_action callbacks.

class ArticlesController < ApplicationController
  before_action :authenticate_user!
  before_action :set_article, only: %i[show edit update destroy]

  def show; end

  private

  def set_article
    @article = Article.find(params[:id])
  end
end

Use Cases:

  • Authentication/authorization
  • Parameter normalization
  • Auditing/logging

Benefits:

  • Centralize pre- and post-processing logic.
  • Keep actions concise.

Drawbacks:

  • Overuse can obscure the action’s core logic.
  • Callback order matters and can introduce subtle bugs.

6. STI vs. Polymorphic Associations vs. Ruby Inheritance

FeatureSTIPolymorphicPlain Ruby Inheritance
DB StructureSingle table + type columnSeparate tables + *_type + *_idNo DB persistence
FlexibilitySubclasses share schemaCan link many models to oneFull OOP, no DB ties
When to UseSubtypes with similar attributesComments, attachments across modelsPure Ruby services, utilities

STI Example:

class Vehicle < ApplicationRecord; end
class Car < Vehicle; end
class Truck < Vehicle; end

All in vehicles table, differentiated by type.

Polymorphic Example:

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

class Post < ApplicationRecord
  has_many :comments, as: :commentable
end

Benefits & Drawbacks:

  • STI: simple table; limited when subclasses diverge on columns.
  • Polymorphic: very flexible; harder to enforce foreign-key constraints.
  • Ruby Inheritance: best for non-persistent logic; no DB coupling.

7. rescue_from in Rails API Controllers

rescue_from declares exception handlers at the controller (or ApplicationController) level:

class Api::BaseController < ActionController::API
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
  rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity

  private

  def render_not_found(e)
    render json: { error: e.message }, status: :not_found
  end

  def render_unprocessable_entity(e)
    render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
  end
end

Benefits:

  • Centralized error handling.
  • Cleaner action code without repetitive begin…rescue.

Drawbacks:

  • Must carefully order rescue_from calls (first match wins).
  • Overly broad handlers can mask unexpected bugs.
Summary

This post has covered advanced Rails concepts with practical examples, advantages, and pitfalls. By understanding these patterns, you can write cleaner, more maintainable Rails applications. Feedback and questions are welcome—let’s keep the conversation going!

Happy Rails Understanding! 🚀

A Complete Guide to Ruby on Rails Security Measures 🛡️

Ruby on Rails is known for its developer-friendly conventions, but it’s also built with security in mind. While the framework provides many features to guard against common threats, it’s up to developers to understand and apply them correctly.

In this post, we’ll walk through essential Rails security measures, tackle real-world threats, and share best practices – with examples for both API-only and full-stack Rails applications.

🚨 Common Web Threats Rails Helps Mitigate

  1. SQL Injection
  2. Cross-Site Scripting (XSS)
  3. Cross-Site Request Forgery (CSRF)
  4. Mass Assignment
  5. Session Hijacking
  6. Insecure Deserialization
  7. Insecure File Uploads
  8. Authentication & Authorization flaws

Let’s explore how Rails addresses these and what you can do to reinforce your app.


1. 🧱 SQL Injection

🛡️ Rails Protection:

Threat: Attackers inject malicious SQL through user inputs to read, modify, or delete database records

Rails uses Active Record with prepared statements to prevent SQL injection by default.

Arel: Build complex queries without string interpolation.

# Safe - uses bound parameters
User.where(email: params[:email])

# ❌ Dangerous - interpolates input directly
User.where("email = '#{params[:email]}'")

# Safe: Parameterized query
User.where("role = ? AND created_at > ?", params[:role], 7.days.ago)

# Using Arel for dynamic conditions
users = User.arel_table
def recent_admins
  User.where(users[:role].eq('admin').and(users[:created_at].gt(7.days.ago)))
end

Tip: Never use string interpolation to build SQL queries. Use .where, .find_by, or Arel methods.

Additional Measures

  • Whitelist Columns: Pass only known column names to dynamic ordering or filtering.
  • Gem: activerecord-security to raise errors on unsafe query methods.

2. 🧼 Cross-Site Scripting (XSS)

Threat: Injection of malicious JavaScript via user inputs, compromising other users’ sessions.

🛡️ Rails Protection

Content Security Policy (CSP): Limit sources of executable scripts.

# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src  :self, :https
  policy.style_src   :self, :https
end

Auto-Escaping: <%= %> escapes HTML; <%== %> and raw do not.

Rails auto-escapes output in views.

<!-- Safe: Escaped -->
<%= user.bio %>

<!-- Unsafe: Unescaped (only use if trusted) -->
<%= raw user.bio %>

In API-only apps: Always sanitize any input returned in JSON if used in web contexts later.

Use gems:

  • sanitize gem to strip malicious HTML
  • loofah for more control (Loofah for robust HTML5 handling and scrubbers.)
# In models or controllers
clean_bio = Loofah.fragment(params[:bio]).scrub!(:prune).to_s

3. 🔐 Cross-Site Request Forgery (CSRF)

🔍 How CSRF Works (Example)

1.Victim logs in to bank.example.com, receiving a session cookie.

2. Attacker crafts a hidden form on attacker.com:

<form action="https://bank.example.com/transfer" method="POST">
  <input type="hidden" name="amount" value="1000">
  <input type="hidden" name="to_account" value="attacker_account">
</form>
<script>document.forms[0].submit();</script>

3. Victim visits attacker.com while still logged into the bank.

4. Browser auto-sends the bank session cookie with the forged POST—and the transfer goes through, because the bank sees a “legitimate” logged-in request.

🛡️ Rails’ CSRF Protection

Rails ships with built-in defenses against CSRF by embedding an unguessable token in forms and verifying it on each non-GET request.

1.protect_from_forgery

In ApplicationController, Rails by default includes:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end

This causes Rails to raise an exception if the token is missing or invalid.

Refer: https://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection/ClassMethods.html

2. Authenticity Token in Forms

All Rails form-builders (form_with, form_for, form_tag) automatically insert:

<input type="hidden" name="authenticity_token" value="…secure random…">

3.Meta Tag for AJAX

Rails also inserts in <head>:

<%= csrf_meta_tags %>

Which renders:

<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="…secure random…" />

Rails UJS or Turbo JS will read these and include the token on XHR/fetch requests.

4.Server-Side Verification

On each POST/PUT/PATCH/DELETE, Rails:

  • Extracts the token (from form param, header X-CSRF-Token, or query string).
  • Compares it to the session-stored token.
  • Rejects the request if they don’t match.

API-only apps:

CSRF is less relevant for stateless APIs using tokens, but if you use session-based authentication, enable CSRF protection:

# application_controller.rb
protect_from_forgery with: :exception

Thought for a couple of seconds

Cross-Site Request Forgery (CSRF) is an attack that tricks a user’s browser into submitting a request (e.g. form submission, link click) to your application without the user’s intention, leveraging the fact that the browser automatically includes credentials (cookies, basic auth headers, etc.) with each request.

🔧 Disabling or Customizing CSRF

♦️ Disable for APIs (stateless JSON endpoints):

class Api::BaseController < ActionController::API skip_before_action :verify_authenticity_token end

♦️ Use Null Session (allowing some API use without exception):

protect_from_forgery with: :null_session

✅ Key Takeaways

  • CSRF exploits the browser’s automatic credential sending.
  • Rails guards by inserting and validating an unguessable token.
  • Always keep protect_from_forgery with: :exception in your base controller for full-stack Rails apps.

4. 🛑 Mass Assignment Vulnerability

Threat: Attackers pass unexpected parameters to update sensitive attributes (e.g., admin=true).

Before Rails 4, mass assignment was a common issue. Now, strong parameters protect against it.

✅ Use Strong Parameters:

# ✅ Safe
def user_params
  params.require(:user).permit(:name, :email)
end

User.create(user_params)

# ❌ Unsafe
User.create(params[:user])

Pro tip: Don’t over-permit, especially with admin or role-based attributes.

Real-World Gotcha

  • Before permitting arrays or nested attributes, validate length and content.
params.require(:order).permit(:total, items: [:product_id, :quantity])

5. 🔒 Secure Authentication

Built-In: has_secure_password

Provides authenticate method.

Uses BCrypt with configurable cost.

# user.rb
class User < ApplicationRecord
  has_secure_password
  # optional: validates length, complexity
  validates :password, length: { minimum: 12 }, format: { with: /(?=.*\d)(?=.*[A-Z])/ }
end

Make sure you have a password_digest column. This uses bcrypt under the hood.

Using Devise

JWT: integrate with devise-jwt for stateless APIs.

Modules: Database Authenticatable, Confirmable, Lockable, Timeoutable, Trackable.

Devise gives you:

  • Password encryption
  • Lockable accounts
  • Timeoutable sessions
  • Token-based authentication for APIs (with devise-jwt)
# config/initializers/devise.rb
Devise.setup do |config|
  config.jwt do |jwt|
    jwt.secret = Rails.application.credentials.devise_jwt_secret
    jwt.dispatch_requests = [['POST', %r{^/login$}]]
    jwt.revocation_requests = [['DELETE', %r{^/logout$}]]
  end
end

6. 🧾 Authorization

Threat: Users access or modify resources beyond their permissions.

Never trust the frontend. Always check permissions server-side.

# ❌ Dangerous
redirect_to dashboard_path if current_user.admin?

# ✅ Use Pundit or CanCanCan
authorize @order

Gems:

  • pundit – lean policy-based authorization
  • cancancan – rule-based authorization

Pundit Example

# app/policies/article_policy.rb
class ArticlePolicy
  attr_reader :user, :article

  def initialize(user, article)
    @user = user
    @article = article
  end

  def update?
    user.admin? || article.author_id == user.id
  end
end

# In controller
def update
  @article = Article.find(params[:id])
  authorize @article
  @article.update!(article_params)
end

Use Existing Auditing Libraries

To track user actions including access, use:

For Rails 8 check the post for Rails own Authentication: https://railsdrop.com/2025/05/07/rails-8-implement-users-authentication-orders-order-items/

7. 🗃️ Secure File Uploads

Threat: Attackers upload malicious files (e.g., scripts, executables).

Use Active Storage securely:

<%= image_tag url_for(user.avatar) %>

Active Storage Best Practices

Validation:

class Photo < ApplicationRecord
  has_one_attached :image
  validate :image_type, :image_size

  private
  def image_type
    return unless image.attached?
    acceptable = ['image/jpeg', 'image/png']
    errors.add(:image, 'must be JPEG or PNG') unless acceptable.include?(image.content_type)
  end

  def image_size
    return unless image.attached?
    errors.add(:image, 'is too big') if image.byte_size > 5.megabytes
  end
end
  • Validate content type:
validates :avatar, content_type: ['image/png', 'image/jpg', 'image/jpeg']

  • Restrict file size.
  • Store uploads in private S3 buckets for sensitive data.
  • Private URLs for sensitive documents (e.g., contracts).
  • Virus Scanning: hook into after_upload to scan with ClamAV (or VirusTotal API).

8. 🧾 HTTP Headers & SSL

Rails helps with secure headers via secure_headers gem (https://github.com/github/secure_headers).

# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
  config.hsts = "max-age=31536000; includeSubDomains"
  config.x_frame_options = "DENY"
  config.x_content_type_options = "nosniff"
  config.x_xss_protection = "1; mode=block"
end

SSL/TLS Force HTTPS:

# config/environments/production.rb
config.force_ssl = true

Ensure HSTS is enabled:
# config/initializers/secure_headers.rb
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
SecureHeaders::Configuration.default do |config|
  config.hsts = "max-age=63072000; includeSubDomains; preload"
end

Key Headers

  • X-Frame-Options: DENY to prevent clickjacking.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.

9. 🧪 Security Testing

  • Use brakeman to detect common vulnerabilities.
bundle exec brakeman

  • Add bundler-audit to scan for insecure gems.
bundle exec bundler-audit check --update

Check the post for more details: https://railsdrop.com/2025/05/05/rails-8-setup-simplecov-brakeman-for-test-coverage-security/

  • Fuzz & Pen Testing: Use tools like ZAP Proxy, OWASP ZAP.
  • Use RSpec tests for role restrictions, parameter whitelisting, and CSRF.
describe "Admin access" do
  it "forbids non-admins from deleting users" do
    delete admin_user_path(user)
    expect(response).to redirect_to(root_path)
  end
end

  • Continuous Integration – Integrate scans in CI pipeline (GitHub Actions example):
jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Brakeman
        run: bundle exec brakeman -o brakeman-report.html
      - name: Bundler Audit
        run: bundle exec bundler-audit check --update

Read the post: Setup 🛠 Rails 8 App – Part 15: Set Up CI/CD ⚙️ with GitHub Actions for Rails 8

10. 🔑 API Security (Extra Measures)

  • Use JWT or OAuth2 for stateless token authentication.
  • Set appropriate CORS headers.

Gem: `rack-cors` (https://github.com/cyu/rack-cors)

Add in your Gemfile:

gem 'rack-cors'
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'your-frontend.com'
    resource '*',
      headers: :any,
      expose: ['Authorization'],
      methods: [:get, :post, :patch, :put, :delete, :options]
  end
end

  • Rate-limit endpoints with Rack::Attack

Include the Gem rack-attack (https://github.com/rack/rack-attack) to your Gemfile.

# In your Gemfile
gem 'rack-attack'
# config/initializers/rack_attack.rb

Rack::Attack.throttle('req/ip', limit: 60, period: 1.minute) do |req|
  req.ip
end

in Rails 8 we can use rate_limit for Controller actions like:

  rate_limit to: 10, within: 1.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }

  • Pagination & Filtering: Prevent large payloads to avoid DoS.

📝 Summary: Best Practices Checklist

✅ Use Strong Parameters
✅ Escape output (no raw unless absolutely trusted)
✅ Sanitize user content
✅ Use Devise or Sorcery for auth
✅ Authorize every resource with Pundit or CanCanCan
✅ Store files safely and validate uploads
✅ Enforce HTTPS in production
✅ Regularly run Brakeman and bundler-audit
✅ Rate-limit APIs with Rack::Attack
✅ Keep dependencies up to date

🔐 Final Thought

Rails does a lot to keep you safe — but security is your responsibility. Follow these practices and treat every external input as potentially dangerous. Security is not a one-time setup — it’s an ongoing process.


Happy and secure coding! 🚀

Guide: Integrating React.js ⚛️ into a Rails 8 Application | Node.js | ESBuild | Virtual DOM- Part 1

1. Introduction and Motivation

Why React?

  • Declarative UI: build complex interfaces by composing small, reusable components.
  • Virtual DOM: efficient updates, smoother user experience.
  • Rich ecosystem: hooks, context, testing tools, and libraries like Redux.
  • Easy to learn once you grasp JSX and component lifecycle.

Why use React in Rails?

  • Leverage Rails’ backend power (ActiveRecord, routing, authentication) with React’s frontend flexibility.
  • Build single-page-app-like interactions within a Rails monolith or progressively enhance ERB views.

2. Prerequisites

  • Ruby 3.4.x installed (recommend using rbenv or RVM or Mise).
  • Rails 8.x (we’ll install below).
  • Node.js (>= 16) and npm or Yarn.
  • Code editor (VS Code, RubyMine, etc.).

Why Node.js is Required for React

React’s ecosystem relies on a JavaScript runtime and package manager:

  • Build tools (ESBuild, Webpack, Babel) run as Node.js scripts to transpile JSX/ES6 and bundle assets.
  • npm/Yarn fetch and manage React and its dependencies from the npm registry.
  • Script execution: Rails generators and custom npm scripts (e.g. rails javascript:install:react, npm run build) need Node.js to execute.

Without Node.js, you cannot install packages or run the build pipeline necessary to compile and serve React components.

What is Node.js?

Node.js is an open-source, cross-platform JavaScript runtime built on Chrome’s V8 engine. It enables JavaScript to be executed on the server (outside the browser) and provides:

  • Server-side scripting: build web servers, APIs, and backend services entirely in JavaScript.
  • Command-line tools: run scripts for tasks like building, testing, or deploying applications.
  • npm ecosystem: access to hundreds of thousands of packages for virtually any functionality, from utility libraries to full frameworks.
  • Event-driven, non-blocking I/O: efficient handling of concurrent operations, making it suitable for real-time applications.

Node.js is the backbone that powers React’s tooling, package management, and build processes.

3. Installing Ruby 3.4 and Rails 8

1. Install Ruby 3.4.0 (example using rbenv):

# install rbenv and ruby-build if not yet installed
brew install rbenv ruby-build
rbenv install 3.4.0
rbenv global 3.4.0
ruby -v   # => ruby 3.4.0p0

Check the post for using Mise as version manager: https://railsdrop.com/2025/02/11/installing-and-setup-ruby-3-rails-8-vscode-ide-on-macos-in-2025/

2. Install Rails 8:

gem install rails -v "~> 8.0"
rails -v   # => Rails 8.0.x

4. Generating a New Rails 8 App

We’ll scaffold a fresh project using ESBuild for JavaScript bundling, which integrates seamlessly with React.

rails new design_studio_react \
  --database=postgresql \
  -j esbuild
cd design_studio_react
  • --database=postgresql: sets PostgreSQL as the database adapter.
  • -j esbuild: configures ESBuild for JS bundling (preferred for React in Rails 8).

4.1 About ESBuild

ESBuild is a next-generation JavaScript bundler and minifier written in Go. Rails 8 adopted ESBuild by default for JavaScript bundling due to its remarkable speed and modern feature set:

  • Blazing-fast builds: ESBuild performs parallel compilation and leverages Go’s concurrency, often completing bundling in milliseconds even for large codebases.
  • Built‑in transpilation: it supports JSX and TypeScript out of the box, so you don’t need separate tools like Babel unless you have highly custom transforms.
  • Tree shaking: ESBuild analyzes import/export usage to eliminate dead code, producing smaller bundles.
  • Plugin system: you can extend ESBuild with plugins for asset handling, CSS bundling, or custom file types.
  • Simplicity: configuration is minimal—Rails’ -j esbuild flag generates sensible defaults, and you can tweak options in package.json or a separate esbuild.config.js.

How Rails Integrates ESBuild

When you run:

rails new design_studio_react --database=postgresql -j esbuild

Rails will:

1. Install the esbuild npm package alongside react dependencies.

2. Generate build scripts in package.json, e.g.:

"scripts": { 
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds", 
"build:watch": "esbuild app/javascript/*.* --bundle --sourcemap --watch --outdir=app/assets/builds" 
}

3. Add a default app/assets/builds output directory and ensure Rails’ asset pipeline picks up the compiled files.

Customizing ESBuild

If you need to tweak ESBuild settings:

Add an esbuild.config.js at your project root:

const esbuild = require('esbuild')

esbuild.build({
  entryPoints: ['app/javascript/application.js'],
  bundle: true,
  sourcemap: true,
  outdir: 'app/assets/builds',
  loader: { '.js': 'jsx', '.png': 'file' },
  define: { 'process.env.NODE_ENV': '"production"' },
}).catch(() => process.exit(1))

Update package.json scripts to use this config:

"scripts": {
  "build": "node esbuild.config.js",
  "build:watch": "node esbuild.config.js --watch"
}

Why ESBuild Matters for React in Rails

  • Developer experience: near-instant rebuilds let you see JSX changes live without delay.
  • Production readiness: built‑in minification and tree shaking keep your asset sizes small.
  • Future-proof: the plugin ecosystem grows, and Rails can adopt newer bundlers (like SWC or Vite) with a similar pattern.

With ESBuild, your React components compile quickly, your development loop tightens, and your production assets stay optimized—making it the perfect companion for a modern Rails 8 + React stack.

5. What is Virtual DOM

The Virtual DOM is one of React’s most important concepts. Let me explain it clearly with examples.

🎯 What is the Virtual DOM?

The Virtual DOM is a JavaScript representation (copy) of the actual DOM that React keeps in memory. It’s a lightweight JavaScript object that describes what the UI should look like.

📚 Real DOM vs Virtual DOM

Real DOM (What the browser uses):
<!-- This is the actual DOM in the browser -->
<div id="todo-app">
  <h1>My Todo List</h1>
  <ul>
    <li>React List</li>
    <li>Build a todo app</li>
  </ul>
</div>
Virtual DOM (React’s JavaScript representation):
// This is React's Virtual DOM representation
{
  type: 'div',
  props: {
    id: 'todo-app',
    children: [
      {
        type: 'h1',
        props: {
          children: 'My Todo List'
        }
      },
      {
        type: 'ul',
        props: {
          children: [
            {
              type: 'li',
              props: {
                children: 'React List'
              }
            },
            {
              type: 'li',
              props: {
                children: 'Build a todo app'
              }
            }
          ]
        }
      }
    ]
  }
}

🔄 How Virtual DOM Works – The Process

Step 1: Initial Render
// Your JSX
const App = () => {
  return (
    <div>
      <h1>My Todo List</h1>
      <ul>
        <li>React List</li>
      </ul>
    </div>
  );
};
// React creates Virtual DOM
const virtualDOM = {
  type: 'div',
  props: {
    children: [
      { type: 'h1', props: { children: 'My Todo List' } },
      { 
        type: 'ul', 
        props: { 
          children: [
            { type: 'li', props: { children: 'React List' } }
          ]
        }
      }
    ]
  }
};
Step 2: State Changes
// When you add a new todo
const App = () => {
  const [todos, setTodos] = useState(['React List']);

  const addTodo = () => {
    setTodos(['React List', 'Build Todo App']); // State change!
  };

  return (
    <div>
      <h1>My Todo List</h1>
      <ul>
        {todos.map(todo => <li key={todo}>{todo}</li>)}
      </ul>
      <button onClick={addTodo}>Add Todo</button>
    </div>
  );
};
Step 3: New Virtual DOM is Created
// React creates NEW Virtual DOM
const newVirtualDOM = {
  type: 'div',
  props: {
    children: [
      { type: 'h1', props: { children: 'My Todo List' } },
      { 
        type: 'ul', 
        props: { 
          children: [
            { type: 'li', props: { children: 'React List' } },
            { type: 'li', props: { children: 'Build Todo App' } } // NEW!
          ]
        }
      },
      { type: 'button', props: { children: 'Add Todo' } }
    ]
  }
};
Step 4: Diffing Algorithm
// React compares old vs new Virtual DOM
const differences = [
  {
    type: 'ADD',
    location: 'ul.children',
    element: { type: 'li', props: { children: 'Build Todo App' } }
  }
];
Step 5: Reconciliation (Updating Real DOM)
// React updates ONLY what changed in the real DOM
const ul = document.querySelector('ul');
const newLi = document.createElement('li');
newLi.textContent = 'Build Todo App';
ul.appendChild(newLi); // Only this line runs!

🚀 Why Virtual DOM is Fast

Without Virtual DOM (Traditional approach):
// Traditional DOM manipulation
function updateTodoList(todos) {
  const ul = document.querySelector('ul');
  ul.innerHTML = ''; // Clear everything!

  todos.forEach(todo => {
    const li = document.createElement('li');
    li.textContent = todo;
    ul.appendChild(li); // Recreate everything!
  });
}
With Virtual DOM (React approach):
// React's approach
function updateTodoList(oldTodos, newTodos) {
  const differences = findDifferences(oldTodos, newTodos);

  differences.forEach(diff => {
    if (diff.type === 'ADD') {
      // Only add the new item
      const li = document.createElement('li');
      li.textContent = diff.todo;
      ul.appendChild(li);
    }
  });
}

🎭 Real Example with Our Todo App

Let’s trace through what happens when you add a todo:

Before Adding Todo:
// Current state
const [todos, setTodos] = useState([
  { id: 1, text: 'React List', completed: false },
  { id: 2, text: 'Build Todo App', completed: false }
]);

// Virtual DOM representation
{
  type: 'ul',
  props: {
    children: [
      { type: 'li', key: 1, props: { children: 'React List ⏳' } },
      { type: 'li', key: 2, props: { children: 'Build Todo App ⏳' } }
    ]
  }
}
After Adding Todo:
// New state
const [todos, setTodos] = useState([
  { id: 1, text: 'React List', completed: false },
  { id: 2, text: 'Build Todo App', completed: false },
  { id: 3, text: 'Master React Hooks', completed: false } // NEW!
]);

// New Virtual DOM
{
  type: 'ul',
  props: {
    children: [
      { type: 'li', key: 1, props: { children: 'React List ⏳' } },
      { type: 'li', key: 2, props: { children: 'Build Todo App ⏳' } },
      { type: 'li', key: 3, props: { children: 'Master React Hooks ⏳' } } // NEW!
    ]
  }
}
React’s Diffing Process:
// React compares and finds:
const changes = [
  {
    type: 'INSERT',
    location: 'ul',
    element: { type: 'li', key: 3, props: { children: 'Master React Hooks ⏳' } }
  }
];

// React updates ONLY what changed:
const ul = document.querySelector('ul');
const newLi = document.createElement('li');
newLi.textContent = 'Master React Hooks ⏳';
ul.appendChild(newLi); // Only this operation!

🎯 Key Benefits of Virtual DOM

1. Performance:
// Without Virtual DOM: Updates entire list
document.querySelector('ul').innerHTML = generateEntireList(todos);

// With Virtual DOM: Updates only what changed
document.querySelector('ul').appendChild(newTodoElement);
2. Predictability:
// You write declarative code
const TodoList = ({ todos }) => (
  <ul>
    {todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
  </ul>
);

// React handles the imperative updates
// You don't need to manually add/remove DOM elements
3. Batching:
// Multiple state updates in one event
const handleButtonClick = () => {
  setTodos([...todos, newTodo]);     // Change 1
  setInputValue('');                 // Change 2
  setCount(count + 1);              // Change 3
};

// React batches these into one DOM update!

🔧 Virtual DOM in Action – Debug Example

You can actually see the Virtual DOM in action:

import React, { useState } from 'react';

const App = () => {
  const [todos, setTodos] = useState(['React List']);

  const addTodo = () => {
    console.log('Before update:', todos);
    setTodos([...todos, 'New Todo']);
    console.log('After update:', todos); // Still old value!
  };

  console.log('Rendering with todos:', todos);

  return (
    <div>
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
      <button onClick={addTodo}>Add Todo</button>
    </div>
  );
};

🎭 Common Misconceptions

❌ “Virtual DOM is always faster”
// For simple apps, Virtual DOM has overhead
// Direct DOM manipulation can be faster for simple operations
document.getElementById('counter').textContent = count;
❌ “Virtual DOM prevents all DOM operations”
// React still manipulates the real DOM
// Virtual DOM just makes it smarter about WHEN and HOW
✅ “Virtual DOM optimizes complex updates”
// When you have many components and complex state changes
// Virtual DOM's diffing algorithm is much more efficient

🧠 Does React show Virtual DOM to the user?

No. The user only ever sees the real DOM.
The Virtual DOM (VDOM) is never shown directly. It’s just an internal tool used by React to optimize how and when the real DOM gets updated.

🧩 What is Virtual DOM exactly?

  • A JavaScript-based, lightweight copy of the real DOM.
  • Stored in memory.
  • React uses it to figure out what changed after state/props updates.

👀 What the user sees:

  • The real, visible HTML rendered to the browser — built from React components.
  • This is called the Real DOM.

🔁 So why use Virtual DOM at all?

✅ Because manipulating the real DOM is slow.

React uses VDOM to:

  1. Build a new virtual DOM after every change.
  2. Compare (diff) it with the previous one.
  3. Figure out the minimum real DOM updates required.
  4. Apply only those changes to the real DOM.

This process is called reconciliation.

🖼️ Visual Analogy

Imagine the Virtual DOM as a sketchpad.
React draws the new state on it, compares it with the old sketch, and only updates what actually changed in the real-world display (real DOM).

✅ TL;DR

QuestionAnswer
Does React show the virtual DOM to user?❌ No. Only the real DOM is ever visible to the user.
What is virtual DOM used for?🧠 It’s used internally to calculate DOM changes efficiently.
Is real DOM updated directly?✅ Yes, but only the minimal parts React determines from the VDOM diff.

🧪 Example Scenario

👤 The user is viewing a React app with a list of items and a button:

<ul>
  <li>Item 1</li>
  <li>Item 2</li>
  ...
  <li>Item 10</li>
</ul>
<button>Read More</button>

When the user clicks “Read More”, the app adds 10 more items to the list.

🧠 Step-by-Step: What Happens Behind the Scenes

✅ 1. User Clicks “Read More” Button

<button onClick={loadMore}>Read More</button>

This triggers a React state update, e.g.:

function loadMore() {
  setItems([...items, ...next10Items]); // updates state
}

🔁 State change → React starts re-rendering

📦 2. React Creates a New Virtual DOM

  • React re-runs the component’s render function.
  • This generates a new Virtual DOM tree (just a JavaScript object structure).

Example of the new VDOM:

{
  type: "ul",
  children: [
    { type: "li", content: "Item 1" },
    ...
    { type: "li", content: "Item 20" } // 10 new items
  ]
}

🧮 3. React Diffs New Virtual DOM with Old One

  • Compares previous VDOM (10 <li> items) vs new VDOM (20 <li> items).
  • Finds that 10 new <li> nodes were added.

This is called the reconciliation process.

⚙️ 4. React Updates the Real DOM

  • React tells the browser:
    “Please insert 10 new <li> elements inside the <ul>.”

Only these 10 DOM operations happen.
❌ React does not recreate the entire <ul> or all 20 items.

🖼️ What the User Sees

On the screen (the real DOM):

<ul>
  <li>Item 1</li>
  ...
  <li>Item 20</li>
</ul>

The user never sees the Virtual DOM — they only see the real DOM updates that React decides are necessary.

🧠 Summary: Virtual DOM vs Real DOM

StepVirtual DOMReal DOM
Before click10 <li> nodes in memory10 items visible on screen
On clickNew VDOM generated with 20 <li> nodesReact calculates changes
DiffCompares new vs old VDOMDetermines: “Add 10 items”
CommitNo UI shown from VDOMOnly those 10 new items added to browser DOM

✅ Key Point

🧠 The Virtual DOM is a tool for React, not something the user sees.
👁️ The user only sees the final, optimized changes in the real DOM.


🎯 Summary

Virtual DOM is React’s:

  1. JavaScript representation of the real DOM
  2. Diffing algorithm that compares old vs new Virtual DOM
  3. Reconciliation process that updates only what changed
  4. Performance optimization for complex applications
  5. Abstraction layer that lets you write declarative code

Think of it as React’s smart assistant that:

  • Remembers what your UI looked like before
  • Compares it with what it should look like now
  • Makes only the necessary changes to the real DOM

This is why you can write simple, declarative code like {todos.map(todo => <li>{todo}</li>)} and React handles all the complex DOM updates efficiently!


🔄 After the Virtual DOM Diff, How React Updates the Real DOM

🧠 Step-by-Step:

  1. React creates a diff between the new and previous virtual DOM trees.
  2. React then creates a list of “instructions” called the update queue.
    • Examples:
      • “Insert <li>Item 11</li> at position 10″
      • “Remove <div> at index 3″
      • “Change text of button to ‘Read Less'”
  3. These changes are passed to React’s reconciliation engine.
  4. React uses the browser’s DOM APIs (document.createElement, appendChild, removeChild, etc.) to apply only the minimal changes.

✅ So instead of doing:

document.body.innerHTML = newHTML; // inefficient, replaces all

React does:

const newEl = document.createElement("li");
newEl.textContent = "Item 11";
ul.appendChild(newEl); // just this

❓ Why Didn’t Browsers Do This Earlier?

Excellent historical question. The short answer is: Browsers give us the tools, but React gave us the strategy.

⚠️ Why browsers didn’t do it automatically:

ReasonExplanation
🧱 Low-level APIsThe browser exposes DOM APIs (appendChild, setAttribute), but they’re imperative — devs must write the logic.
🤯 ComplexityManaging DOM efficiently across many updates (nested, reordered, conditional elements) is hard and bug-prone manually.
🔁 Manual state syncingBefore React, developers had to manually keep UI in sync with state. That logic got complex and messy fast.
📦 No built-in abstractionBrowsers don’t offer a built-in “virtual diff engine” or abstraction like React’s VDOM.

🤖 What React Added That Browsers Don’t

FeatureBrowser DOMReact (with VDOM)
Efficient diffing❌ No✅ Yes (reconciliation)
Declarative UI❌ No✅ Yes (return <UI />)
Component abstraction❌ No✅ Yes (function/class components)
State-driven rendering❌ Manual✅ Built-in
Minimal updates❌ Up to you✅ Automatic via VDOM

✅ TL;DR

  • React calculates exactly what changed via the virtual DOM diffing.
  • It then uses native DOM APIs to update only what’s necessary in the real DOM.
  • Browsers give you low-level control, but not an optimized strategy for updating UI based on state — React filled that gap beautifully.

Now Let’s break down how a React app starts after you run:

npx create-react-app my-app
cd my-app
npm start

What actually happens behind the scenes? Let’s unpack it step-by-step 👇

⚙️ Step 1: npx create-react-app — What It Does

This command:

  • Downloads and runs the latest version of the create-react-app tool (CRA).
  • Sets up a project with:
    • A preconfigured Webpack + Babel build system
    • Development server
    • Scripts and dependencies
  • Installs React, ReactDOM, and a bunch of tools inside node_modules.

Key folders/files created:

my-app/
├── node_modules/
├── public/
├── src/
│   └── index.js       👈 main entry point
├── package.json

Step 2: npm start — How the App Runs

When you run:

npm start

It’s actually running this line from package.json:

"scripts": {
  "start": "react-scripts start"
}

So it calls:

react-scripts start

🧠 What is react-scripts?

react-scripts is a package from Facebook that:

  • Runs a development server using Webpack Dev Server
  • Compiles JS/JSX using Babel
  • Watches your files for changes (HMR)
  • Starts a browser window at http://localhost:3000

It configures:

  • Webpack
  • Babel
  • ESLint
  • PostCSS
  • Source maps
    … all behind the scenes, so you don’t have to set up any configs manually.

📦 Libraries Involved

Tool / LibraryPurpose
ReactCore UI library (react)
ReactDOMRenders React into actual DOM (react-dom)
WebpackBundles your JS, CSS, images, etc.
BabelConverts modern JS/JSX to browser-friendly JS
Webpack Dev ServerStarts dev server with live reloading
react-scriptsRuns all the above with pre-made configs

🏗️ Step 3: Entry Point — src/index.js

The app starts here:

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

  • ReactDOM.createRoot(...) finds the <div id="root"> in public/index.html.
  • Then renders the <App /> component into it.
  • The DOM inside the browser updates — and the user sees the UI.

✅ TL;DR

StepWhat Happens
npx create-react-appSets up a full React project with build tools
npm startCalls react-scripts start, which runs Webpack dev server
react-scriptsHandles build, hot reload, and environment setup
index.jsLoads React and renders your <App /> to the browser DOM
Browser OutputYou see your live React app at localhost:3000

6. Installing and Configuring React

Rails 8 provides a generator to bootstrap React + ESBuild.

  1. Run the React installer:
    rails javascript:install:react
    This will:
    • Install react and react-dom via npm.
    • Create an example app/javascript/components/HelloReact.jsx component.
    • Configure ESBuild to transpile JSX.
  2. Verify your application layout:
    In app/views/layouts/application.html.erb, ensure you have:
    <%= javascript_include_tag "application", type: "module", defer: true %>
  3. Mount the React component:
    Replace (or add) a div placeholder in an ERB view, e.g. app/views/home/index.html.erb:<div id="hello-react" data-props="{}"></div>
  4. Initialize mount point
    In app/javascript/application.js:
import "./components"

In app/javascript/components/index.js:

import React from "react"
import { createRoot } from "react-dom/client"
import HelloReact from "./HelloReact"

document.addEventListener("DOMContentLoaded", () => {
  const container = document.getElementById("hello-react")
  if (container) {
    const root = createRoot(container)
    const props = JSON.parse(container.dataset.props || "{}")
    root.render(<HelloReact {...props} />)
  }
})

Your React component will now render within the Rails view!

See you in Part 2 … 🚀

Setup 🛠 Rails 8 App – Part 16: Implementing Authentication, Users, Orders, and Order Items

Let’s now move onto create Authentication for our application.

Modern e‑commerce applications need robust user authentication, clear role‑based access, and an intuitive ordering system. In this post, we’ll walk through how to:

  1. Add Rails’ built‑in authentication via has_secure_password.
  2. Create a users table with roles for customers and admins.
  3. Build an orders table to capture overall transactions.
  4. Create order_items to track each product variant in an order.

Throughout, we’ll leverage PostgreSQL’s JSONB for flexible metadata, and we’ll use Rails 8 conventions for migrations and models.


Automatic Authentication For Rails 8 Apps

bin/rails generate authentication

This creates all the necessary files for users and sessions.

Create Authentication Manually

1. Create users table and user model

✗ rails g migration create_users

# users migration
class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users do |t|
      t.string   :email,           null: false, index: { unique: true }
      t.string   :password_digest, null: false
      t.string   :role,            null: false, default: "customer"
      t.string   :first_name
      t.string   :last_name
      t.jsonb    :metadata,        null: false, default: {}
      t.timestamps
    end

    # You can later set up an enum in the User model:
    # enum role: { customer: "customer", admin: "admin" }
  end
end

✗ rails g model user

# User model
class User < ApplicationRecord
  has_secure_password
  enum :role, {
    customer:  "customer",  
    admin:     "admin"      
  }
  has_many :orders
end

2. Authenticating with has_secure_password

Rails ships with bcrypt support out of the box. To enable it:

  1. Uncomment the following line in your Gemfile.
    # gem "bcrypt", "~> 3.1.7"
  2. Run bundle install.
  3. In your migration, create a password_digest column:
create_table :users do |t|
  t.string :email,           null: false, index: { unique: true }
  t.string :password_digest, null: false
  # ... other fields ...
end

  1. In app/models/user.rb, enable:
class User < ApplicationRecord
  has_secure_password
  # ...
end

This gives you user.authenticate(plain_text_password) and built‑in validation that a password is present on create.

3. Setting Up Users with Roles

We often need both customers and admins. Let’s create a role column with a default of "customer":

create_table :users do |t|
  t.string :role, null: false, default: "customer"
  # ...
end

In the User model you can then define an enum:

class User < ApplicationRecord
  ......
  enum :role, {
    customer:  "customer",  
    admin:     "admin"      
  }
end

This lets you call current_user.admin? or User.customers for scopes.

user.customer!   # sets role to "customer"
user.admin?      # => false

Rails built-in enum gives you a quick way to map a column to a fixed set of values, and it:

  1. Defines predicate and bang methods
  2. Adds query scopes
  3. Provides convenient helpers for serialization, validations, etc.

4. Building the Orders Table

Every purchase is represented by an Order. Key fields:

  • user_id (foreign key)
  • total_price (decimal with scale 2)
  • status (string; e.g. pending, paid, shipped)
  • shipping_address (JSONB): allows storing a full address object with flexible fields (street, city, postcode, country, and even geolocation) without altering your schema. You can index JSONB columns (GIN) to efficiently query nested fields, and you avoid creating a separate addresses table unless you need relationships or reuse.
  • placed_at (datetime, optional): records the exact moment the order was completed, independent of when the record was created. Making this optional lets you distinguish between draft/in-progress orders (no placed_at yet) and finalized purchases.
  • Timestamps
  • placed_at (datetime, optional): records the exact moment the order was completed, independent of when the record was created. Making this optional lets you distinguish between draft/in-progress orders (no placed_at yet) and finalized purchases.
  • Timestamps and an optional placed_at datetime
✗ rails g migration create_orders

# orders migration
class CreateOrders < ActiveRecord::Migration[8.0]
  def change
    create_table :orders do |t|
      t.references :user, null: false, foreign_key: true, index: true
      t.decimal    :total_price, precision: 12, scale: 2, null: false, default: 0.0
      t.string     :status,      null: false, default: "pending", index: true
      t.jsonb      :shipping_address, null: false, default: {}
      t.datetime   :placed_at
      t.timestamps
    end

    # Example statuses: pending, paid, shipped, cancelled
  end
end

In app/models/order.rb:

✗ rails g model order

class Order < ApplicationRecord
  belongs_to :user
  has_many   :order_items, dependent: :destroy
  has_many   :product_variants, through: :order_items

  STATUSES = %w[pending paid shipped cancelled]
  validates :status, inclusion: { in: STATUSES }
end

5. Capturing Each Item: order_items

To connect products to orders, we use an order_items join table. Each row stores:

  • order_id and product_variant_id as FKs
  • quantity, unit_price, and any discount_percent
  • Optional JSONB metadata for special instructions
✗ rails g migration create_order_items

# order_items migration
class CreateOrderItems < ActiveRecord::Migration[8.0]
  def change
    create_table :order_items do |t|
      t.references :order,           null: false, foreign_key: true, index: true
      t.references :product_variant, null: false, foreign_key: true, index: true
      t.integer    :quantity,        null: false, default: 1
      t.decimal    :unit_price,      precision: 10, scale: 2, null: false
      t.decimal    :discount_percent, precision: 5, scale: 2, default: 0.0
      t.jsonb      :metadata,        null: false, default: {}
      t.timestamps
    end

    # Composite unique index to prevent duplicate variant per order
    add_index :order_items, [:order_id, :product_variant_id], unique: true, name: "idx_order_items_on_order_and_variant"
  end

Model associations:

✗ rails g model order_item

class OrderItem < ApplicationRecord
  belongs_to :order
  belongs_to :product_variant

  validates :quantity, numericality: { greater_than: 0 }
end

6. Next Steps: Controllers & Authorization

  • Controllers: Scaffold UsersController, SessionsController (login/logout), OrdersController, and nested OrderItemsController under orders or use a service object to build carts.
  • Authorization: Once role is set, integrate Pundit or CanCanCan to restrict admin actions (creating products, managing variants) and customer actions (viewing own orders).
  • Views/Frontend: Tie it all together with forms for signup/login, a product catalog with “Add to Cart”, a checkout flow, and an admin dashboard for product management.

7. Scaffolding Controllers & Views (TailwindCSS Rails 4.2.3)

Generate Controllers & Routes

✗ rails generate controller Users new create index show edit update destroy --skip-routes
create  app/controllers/users_controller.rb
      invoke  tailwindcss
      create    app/views/users
      create    app/views/users/new.html.erb
      create    app/views/users/create.html.erb
      create    app/views/users/index.html.erb
      create    app/views/users/show.html.erb
      create    app/views/users/edit.html.erb
      create    app/views/users/update.html.erb
      create    app/views/users/destroy.html.erb
      invoke  test_unit
      create    test/controllers/users_controller_test.rb
      invoke  helper
      create    app/helpers/users_helper.rb
      invoke    test_unit
✗ rails generate controller Sessions new create destroy --skip-routes
create  app/controllers/sessions_controller.rb
      invoke  tailwindcss
      create    app/views/sessions
      create    app/views/sessions/new.html.erb
      create    app/views/sessions/create.html.erb
      create    app/views/sessions/destroy.html.erb
      invoke  test_unit
      create    test/controllers/sessions_controller_test.rb
      invoke  helper
      create    app/helpers/sessions_helper.rb
      invoke    test_unit
✗ rails generate controller Orders index show new create edit update destroy --skip-routes
      create  app/controllers/orders_controller.rb
      invoke  tailwindcss
      create    app/views/orders
      create    app/views/orders/index.html.erb
      create    app/views/orders/show.html.erb
      create    app/views/orders/new.html.erb
      create    app/views/orders/create.html.erb
      create    app/views/orders/edit.html.erb
      create    app/views/orders/update.html.erb
      create    app/views/orders/destroy.html.erb
      invoke  test_unit
      create    test/controllers/orders_controller_test.rb
      invoke  helper
      create    app/helpers/orders_helper.rb
      invoke    test_unit
 ✗ rails generate controller OrderItems create update destroy --skip-routes
      create  app/controllers/order_items_controller.rb
      invoke  tailwindcss
      create    app/views/order_items
      create    app/views/order_items/create.html.erb
      create    app/views/order_items/update.html.erb
      create    app/views/order_items/destroy.html.erb
      invoke  test_unit
      create    test/controllers/order_items_controller_test.rb
      invoke  helper
      create    app/helpers/order_items_helper.rb
      invoke    test_unit

In config/routes.rb, nest order_items under orders and add session routes:

Rails.application.routes.draw do
  resources :users
n
  resources :sessions, only: %i[new create destroy]
  get    '/login',  to: 'sessions#new'
  post   '/login',  to: 'sessions#create'
  delete '/logout', to: 'sessions#destroy'

  resources :orders do
    resources :order_items, only: %i[create update destroy]
  end

  root 'products#index'
end

By the end, you’ll have a fully functional e‑commerce back end: secure auth, order tracking, and clear user roles.


How to setup your First User🙍🏻‍♂️ in the system

The very first user you should set up is:

An admin user — to create/manage products, variants, and handle backend tasks.

Here’s the best approach:

Best Practice: Seed an Admin User

Instead of manually creating it through the UI (when no one can log in yet), the best and safest approach is to use db/seeds.rb to create an initial admin user.

Why?

  • You can reliably recreate it on any environment (local, staging, production).
  • You can script strong defaults (like setting a secure admin email/password).

🔒 Tip: Use ENV Variables

For production, never hardcode admin passwords directly in seeds.rb. Instead, do:

admin_password = ENV.fetch("ADMIN_PASSWORD")

and pass it as:

ADMIN_PASSWORD=SomeStrongPassword rails db:seed

This keeps credentials out of your Git history.

🛠 Option 1: Add Seed Data db/seeds.rb

Add a block in db/seeds.rb that checks for (or creates) an admin user:

# db/seeds.rb

email    = ENV.fetch("ADMIN_EMAIL") { abort "Set ADMIN_EMAIL" }
password = ENV.fetch("ADMIN_PASSWORD") { abort "Set ADMIN_PASSWORD" }

User.find_or_create_by!(email: admin_email) do |user|
  user.password              = admin_password
  user.password_confirmation = admin_password
  user.role                   = "admin"
  user.first_name             = "Site"
  user.last_name              = "Admin"
end

puts "→ Admin user: #{admin_email}"

Then run:

rails db:seed
  1. Pros:
    • Fully automated and idempotent—you can run db:seed anytime without creating duplicates.
    • Seed logic lives with your code, so onboarding new team members is smoother.
    • You can wire up ENV vars for different credentials in each environment (dev/staging/prod).
  2. Cons:
    • Seeds can get cluttered over time if you add lots of test data.
    • Must remember to re-run seeds after resetting the database.

🛠 Option 2: Custom Rake task or Thor script

Create a dedicated task under lib/tasks/create_admin.rake:

namespace :admin do
  desc "Create or update the first admin user"
  task create: :environment do
    email    = ENV.fetch("ADMIN_EMAIL")    { abort "Set ADMIN_EMAIL" }
    password = ENV.fetch("ADMIN_PASSWORD") { abort "Set ADMIN_PASSWORD" }

    user = User.find_or_initialize_by(email: email)
    user.password              = password
    user.password_confirmation = password
    user.role                   = "admin"
    user.save!

    puts "✅ Admin user #{email} created/updated"
  end
end

Run it with:

ADMIN_EMAIL=foo@bar.com ADMIN_PASSWORD=topsecret rails admin:create
  1. Pros:
    • Keeps seed file lean—admin-creation logic lives in a focused task.
    • Enforces presence of ENV vars (you won’t accidentally use a default password in prod).
  2. Cons:
    • Slightly more setup than plain seeds, though it’s still easy to run.

I choose for Option 2, because it is namespaced and clear what is the purpose. But in seed there will be lot of seed data together make it difficult to identify a particular task.

🛡 Why is This Better?

✅ No need to expose a sign-up page to create the very first admin.
✅ You avoid manual DB entry or Rails console commands.
✅ You can control/rotate the admin credentials easily.
✅ You can add additional seed users later if needed (for demo or testing).

📝 Summary

Seed an initial admin user
✅ Add a role check (admin? method)
✅ Lock down sensitive parts of the app to admin
✅ Use ENV vars in production for passwords


Enjoy Rails 🚀!

Setup 🛠 Rails 8 App – Part 15: Set Up CI/CD ⚙️ with GitHub Actions for Rails 8

Prerequisites

Our System Setup

  • Ruby version: 3.4.1
  • Rails version: 8.0.2
  • JavaScript tooling: using rails default tubo-stream, NO nodeJS or extra js

We would love to see:

  • RuboCop linting Checks
  • SimpleCov test coverage report
  • Brakeman security scan

Here’s how to set up CI that runs on every push, including pull requests:

1. Create GitHub Actions Workflow

Create this file: .github/workflows/ci.yml

name: Rails CI

# Trigger on pushes to main or any feature branch, and on PRs targeting main
on:
  push:
    branches:
      - main
      - 'feature/**'
  pull_request:
    branches:
      - main

jobs:
  # 1) Lint job with RuboCop
  lint:
    name: RuboCop Lint
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.4.1
          bundler-cache: true

      - name: Install dependencies
        run: |
          sudo apt-get update -y
          sudo apt-get install -y libpq-dev
          bundle install --jobs 4 --retry 3

      - name: Run RuboCop
        run: bundle exec rubocop --fail-level E

  # 2) Test job with Minitest
  test:
    name: Minitest Suite
    runs-on: ubuntu-latest
    needs: lint

    services:
      postgres:
        image: postgres:15
        ports:
          - 5432:5432
        env:
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:password@localhost:5432/test_db

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.4.1
          bundler-cache: true

      - name: Install dependencies
        run: |
          sudo apt-get update -y
          sudo apt-get install -y libpq-dev
          bundle install --jobs 4 --retry 3

      - name: Set up database
        run: |
          bin/rails db:create
          bin/rails db:schema:load

      - name: Run Minitest
        run: bin/rails test
  # 3) Security job with Brakeman
  security:
    name: Brakeman Scan
    runs-on: ubuntu-latest
    needs: [lint, test]

    steps:
      - uses: actions/checkout@v3
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.4.1
          bundler-cache: true

      - name: Install Brakeman
        run: bundle install --jobs 4 --retry 3

      - name: Run Brakeman
        run: bundle exec brakeman --exit-on-warnings

How this works:

  1. on.push & on.pull_request:
    • Runs on any push to main or feature/**, and on PRs targeting main.
  2. lint job:
    • Checks out code, sets up Ruby 3.4.1, installs gems (with bundler-cache), then runs bundle exec rubocop --fail-level E to fail on any error-level offenses.
  3. test job:
    • Depends on the lint job (needs: lint), so lint must pass first.
    • Spins up a PostgreSQL 15 service, sets DATABASE_URL for Rails, creates & loads the test database, then runs your Minitest suite with bin/rails test.

🛠 What Does .github/dependabot.yml Do?

This YAML file tells Dependabot:
♦️ Which dependencies to monitor
♦️ Where (which directories) to look for manifest files
♦️ How often to check for updates
♦️ What package ecosystems (e.g., RubyGems, npm, Docker) are used
♦️ Optional rules like versioning, reviewer assignment, and update limits

Dependabot then opens automated pull requests (PRs) in your repository when:

  • There are new versions of dependencies
  • A security advisory affects one of your dependencies

This helps you keep your app up to date and secure without manual tracking.

🏗 Example: Typical .github/dependabot.yml

Here’s a sample for a Ruby on Rails app:

version: 2
updates:
  - package-ecosystem: bundler
    directory: "/"
    schedule:
      interval: weekly
    open-pull-requests-limit: 5
    rebase-strategy: auto
    ignore:
      - dependency-name: rails
        versions: ["7.x"]
  - package-ecosystem: github-actions
    directory: "/"
    schedule:
      interval: weekly

♦️ Place the .github/dependabot.yml file in the .github directory of your repo root.
♦️ Tailor the schedule and limits to your team’s capacity.
♦️ Use the ignore block carefully if you deliberately skip certain updates (e.g., major version jumps).
♦️ Combine it with branch protection rules so Dependabot PRs must pass tests before merging.

🚀 Steps to Push and Test Your CI

You can push both files (ci.yml and dependabot.yml) together in one commit

Here’s a step-by-step guide for testing that your CI works right after the push.

1️⃣ Stage and commit your files

git add .github/workflows/ci.yml .github/dependabot.yml
git commit -m 'feat: Add github actions CI workflow Close #23'

2️⃣ Push to a feature branch
(for example, if you’re working on feature/github-ci):

git push origin feature/github-ci

3️⃣ Open a Pull Request

  • Go to GitHub → your repository → create a pull request from feature/github-ci to main.

4️⃣ Watch GitHub Actions run

  • Go to the Pull Request page.
  • You should see a yellow dot / pending check under “Checks”.
  • Click the “Details” link next to the check (or go to the Actions tab) to see live logs.

✅ How to Know It’s Working

✔️ If all your jobs (e.g., RuboCop Lint, Minitest Suite) finish with green checkmarks, your CI setup is working!

❌ If something fails, you’ll get a red X and the logs will show exactly what failed.

So what’s the problem. Check details.

Check brakeman help for further information about the option.

➜  design_studio git:(feature/github-ci) brakeman --help | grep warn
    -z, --[no-]exit-on-warn          Exit code is non-zero if warnings found (Default)
        --ensure-ignore-notes        Fail when an ignored warnings does not include a note

Modify the option and run again:

run: bundle exec brakeman --exit-on-warn

Push the code and check all checks are passing. ✅

🛠 How to Test Further

If you want to trigger CI without a PR, you can push directly to main:

git checkout main
git merge feature/setup-ci
git push origin main

Note: Make sure your .github/workflows/ci.yml includes:

on:
  push:
    branches: [main, 'feature/**']
  pull_request:
    branches: [main]

This ensures CI runs on both pushes and pull requests.

🧪 Pro Tip: Break It Intentionally

If you want to see CI fail, you can:

  • Add a fake RuboCop error (like an unaligned indent).
  • Add a failing test (assert false).
  • Push and watch the red X appear.

This is a good way to verify your CI is catching problems!


Happy Rails CI setup! 🚀

Rails 8 App: Adding SimpleCov 🧾 & Brakeman 🔰 To Our Application For CI/CD Setup

Ensuring code quality and security in a Rails application is critical – especially as your project grows. In this post, we’ll walk through integrating two powerful tools into your Rails 8 app:

  1. SimpleCov: for measuring and enforcing test coverage
  2. Brakeman: for automated static analysis of security vulnerabilities

By the end, you’ll understand why each tool matters, how to configure them, and the advantages they bring to your development workflow.

Why Code Coverage & Security Scanning Matter

  • Maintainability
    Tracking test coverage ensures critical paths are exercised by your test suite. Over time, you can guard against regressions and untested code creeping in.
  • Quality Assurance
    High coverage correlates with fewer bugs: untested code is potential technical debt. SimpleCov gives visibility into what’s untested.
  • Security
    Rails apps can be vulnerable to injection, XSS, mass assignment, and more. Catching these issues early, before deployment, dramatically reduces risk.
  • Compliance & Best Practices
    Many organizations require minimum coverage thresholds and regular security scans. Integrating these tools automates compliance.

Part 1: Integrating SimpleCov for Test Coverage

1. Add the Gem

In your Gemfile, under the :test group, add:

group :test do
  gem 'simplecov', require: false
end

Then run:

bundle install

2. Configure SimpleCov

Create (or update) test/test_helper.rb (for Minitest) before any application code is loaded:

require 'simplecov'
SimpleCov.start 'rails' do
  coverage_dir 'public/coverage'           # output directory
  minimum_coverage 90               # fail if coverage < 90%
  add_filter '/test/'               # ignore test files themselves
  add_group 'Models', 'app/models'
  add_group 'Controllers', 'app/controllers'
  add_group 'Jobs', 'app/jobs'
  add_group 'Libraries', 'lib'
end

# Then require the rest of your test setup
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
require 'rails/test_help'
# ...

Tip: You can customize groups, filters, and thresholds. If coverage dips below the set minimum, your CI build will fail.

Note: coverage_dir should be modified to public/coverage. Else you cannot access the html publically.

3. Run Your Tests & View the Report

✗ bin/rails test
≈ tailwindcss v4.1.3

Done in 46ms
Running 10 tests in a single process (parallelization threshold is 50)
Run options: --seed 63363

# Running:

..........

Finished in 0.563707s, 17.7397 runs/s, 60.3150 assertions/s.
10 runs, 34 assertions, 0 failures, 0 errors, 0 skips
Coverage report generated for Minitest to /Users/abhilash/rails/design_studio/public/coverage.
Line Coverage: 78.57% (88 / 112)
Line coverage (78.57%) is below the expected minimum coverage (90.00%).
SimpleCov failed with exit 2 due to a coverage related error

Once tests complete, open http://localhost:3000/coverage/index.html#_AllFiles in your browser:

  • A color-coded report shows covered (green) vs. missed (red) lines.
  • Drill down by file or group to identify untested code.

We get 78.57% only coverage and our target is 90% coverage. Let’s check where we missed the tests. ProductsController 82%. We missed coverage for #delete_image action. Let’s add it and check again.

Let’s add Product Controller json requests test cases for json error response and add the ApplicationControllerTest for testing root path.

Now we get: 88.3%

Now we have to add some Test cases for Product model.

Now we get: 92.86% ✅

4. Enforce in CI

In your CI pipeline (e.g. GitHub Actions), ensure:

- name: Run tests with coverage
  run: |
    bundle exec rails test
    # Optionally upload coverage to Coveralls or Codecov

If coverage < threshold, the job will exit non-zero and fail.


Part 2: Incorporating Brakeman for Security Analysis

1. Add Brakeman to Your Development Stack

You can install Brakeman as a gem (development-only) or run it via Docker/CLI. Here’s the gem approach:

group :development do
  gem 'brakeman', require: false
end

Then:

bundle install

2. Basic Usage

From your project root, simply run:

✗ bundle exec brakeman

Generating report...

== Brakeman Report ==

Application Path: /Users/abhilash/rails/design_studio
Rails Version: 8.0.2
Brakeman Version: 7.0.2
Scan Date: 2025-05-07 11:06:36 +0530
Duration: 0.35272 seconds
Checks Run: BasicAuth, BasicAuthTimingAttack, CSRFTokenForgeryCVE, ....., YAMLParsing

== Overview ==

Controllers: 2
Models: 3
Templates: 12
Errors: 0
Security Warnings: 0

== Warning Types ==


No warnings found

By default, Brakeman:

  • Scans app/, lib/, config/, etc.
  • Outputs a report in the terminal and writes brakeman-report.html.

3. Customize Your Scan

Create a config/brakeman.yml to fine-tune:

ignored_files:
  - 'app/controllers/legacy_controller.rb'
checks:
  - mass_assignment
  - cross_site_scripting
  - sql_injection
skip_dev: true                 # ignores dev-only gems
quiet: true                     # suppress verbose output

Run with:

bundle exec brakeman -c config/brakeman.yml -o public/security_report.html

Theconfig/brakeman.yml file is not added by default. You can add the file by copying the contents from: https://gist.github.com/abhilashak/038609f1c35942841ff8aa5e4c88b35b

Check: http://localhost:3000/security_report.html

4. CI Integration

In GitHub Actions:

- name: Run Brakeman security scan
  run: |
    bundle exec brakeman -q -o brakeman.json
- name: Upload Brakeman report
  uses: actions/upload-artifact@v3
  with:
    name: security-report
    path: brakeman.json

Optionally, you can fail the build if new warnings are introduced by comparing against a baseline report.


Advantages of Using SimpleCov & Brakeman Together

AspectSimpleCovBrakeman
PurposeTest coverage metricsStatic security analysis
Fail-fastFails when coverage drops below thresholdCan be configured to fail on new warnings
VisibilityColorized HTML coverage reportDetailed HTML/JSON vulnerability report
CI/CD ReadyIntegrates seamlessly with most CI systemsCLI-friendly, outputs machine-readable data
CustomizableGroups, filters, thresholdsChecks selection, ignored files, baseline

Together, they cover two critical quality dimensions:

  1. Quality & Maintainability (via testing)
  2. Security & Compliance (via static analysis)

Automating both checks in your pipeline means faster feedback, fewer production issues, and higher confidence when shipping code.


Best Practices & Tips

  • Threshold for SimpleCov: Start with 80%, then gradually raise to 90–95% over time.
  • Treat Brakeman Warnings Seriously: Not all findings are exploitable, but don’t ignore them—triage and document why you’re suppressing any warning.
  • Baseline Approach: Use a baseline report for Brakeman so your build only fails on newly introduced warnings, not historical ones.
  • Schedule Periodic Full Scans: In addition to per-PR scans, run a weekly scheduled Brakeman job to catch issues from merged code.
  • Combine with Other Tools: Consider adding gem like bundler-audit for known gem vulnerabilities.

Conclusion

By integrating SimpleCov and Brakeman into your Rails 8 app, you establish a robust safety net that:

  • Ensures new features are properly tested
  • Keeps an eye on security vulnerabilities
  • Automates quality gates in your CI/CD pipeline

These tools are straightforward to configure and provide immediate benefits – improved code confidence, faster code reviews, and fewer surprises in production. Start today, and make code quality and security first-class citizens in your Rails workflow!

Happy Rails CI/CD Integration .. 🚀