Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/controllers/users/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

class Users::InvitationsController < Devise::InvitationsController
# GET /users/invitation/accept?invitation_token=abcdef123456
def edit
set_minimum_password_length
# Ensure the invitation_token is set on the resource from the URL parameter
resource.invitation_token = params[:invitation_token]

# Removed logging of invitation tokens for security reasons


render :edit
end

# PUT /users/invitation
def update
# Removed logging of invitation tokens for security reasons
super
end

protected

# Permit the invitation_token parameter
def update_resource_params
params.require(resource_name).permit(:invitation_token, :password, :password_confirmation)
end
end
4 changes: 2 additions & 2 deletions app/views/devise/invitations/edit.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<div class="password-box px-4 pb-3">
<h2 class="my-3">Send invitation</h2>

<%= form_with(model: resource, as: resource_name, url: invitation_path(resource_name), html: {method: :put}) do |f| %>
<%= form_with(model: resource, as: resource_name, url: invitation_path(resource_name), local: true, html: {method: :put}) do |f| %>
<%= render "/shared/error_messages", resource: resource %>
<%= f.hidden_field :invitation_token, readonly: true %>
<%= f.hidden_field :invitation_token %>

<% if f.object.class.require_password_on_accepting %>
<div class="input-style-1">
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
mount Rswag::Api::Engine => "/api-docs"

devise_for :all_casa_admins, path: "all_casa_admins", controllers: {sessions: "all_casa_admins/sessions"}
devise_for :users, controllers: {sessions: "users/sessions", passwords: "users/passwords"}
devise_for :users, controllers: {sessions: "users/sessions", passwords: "users/passwords", invitations: "users/invitations"}
authenticate :all_casa_admins do
mount PgHero::Engine, at: "pg_dashboard", constraints: lambda { |request|
admin = request.env["warden"].user(:all_casa_admin)
Expand Down
236 changes: 236 additions & 0 deletions spec/requests/users/invitations_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe "Users::InvitationsController", type: :request do
let(:casa_org) { create(:casa_org) }
let(:volunteer) { create(:volunteer, casa_org: casa_org) }

describe "GET /users/invitation/accept" do
context "with valid invitation_token" do
before do
volunteer.invite!
end

it "sets invitation_token on the resource" do
get accept_user_invitation_path(invitation_token: volunteer.raw_invitation_token)

expect(response).to have_http_status(:success)
expect(response.body).to include("Set my password")
expect(response.body).to include(volunteer.raw_invitation_token)
end

it "renders the edit template" do
get accept_user_invitation_path(invitation_token: volunteer.raw_invitation_token)

expect(response).to render_template(:edit)
end
end

context "without invitation_token" do
it "redirects away" do
get accept_user_invitation_path

# Devise may redirect to root or sign_in depending on configuration
expect(response).to have_http_status(:redirect)
end
end
end

describe "PUT /users/invitation" do
let(:valid_password) { "Password123!" }

context "with valid invitation_token and password" do
before do
volunteer.invite!
end

let(:params) do
{
user: {
invitation_token: volunteer.raw_invitation_token,
password: valid_password,
password_confirmation: valid_password
}
}
end

it "accepts the invitation" do
expect {
put user_invitation_path, params: params
}.to change { volunteer.reload.invitation_accepted_at }.from(nil)
end

it "sets the password" do
put user_invitation_path, params: params

volunteer.reload
expect(volunteer.valid_password?(valid_password)).to be true
end

it "signs in the user" do
put user_invitation_path, params: params

expect(controller.current_user).to eq(volunteer)
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing controller.current_user in a request spec is unreliable and may not work as expected. Request specs are black-box tests that should only verify the HTTP response. Consider checking that the user is signed in by verifying the response redirects to an authenticated page or by checking the session directly. Alternatively, if you need to test controller internals, use a controller spec instead.

Suggested change
expect(controller.current_user).to eq(volunteer)
# In request specs, we can't reliably access controller.current_user.
# Instead, check that the session contains the user's ID (Devise stores it as "warden.user.user.key").
expect(session["warden.user.user.key"][0][0]).to eq(volunteer.id)

Copilot uses AI. Check for mistakes.
end

it "redirects after acceptance" do
put user_invitation_path, params: params

expect(response).to redirect_to(authenticated_user_root_path)
end
end

context "with invalid invitation_token" do
let(:params) do
{
user: {
invitation_token: "invalid_token",
password: valid_password,
password_confirmation: valid_password
}
}
end

it "does not accept the invitation" do
put user_invitation_path, params: params

expect(response).to have_http_status(:success)
expect(response.body).to include("Invitation token is invalid")
end
end

context "with blank invitation_token" do
let(:params) do
{
user: {
invitation_token: "",
password: valid_password,
password_confirmation: valid_password
}
}
end

it "shows validation error" do
put user_invitation_path, params: params

expect(response).to have_http_status(:success)
expect(response.body).to include("Invitation token")
end
end

context "with mismatched passwords" do
before do
volunteer.invite!
end

let(:params) do
{
user: {
invitation_token: volunteer.raw_invitation_token,
password: valid_password,
password_confirmation: "DifferentPassword123!"
}
}
end

it "does not accept the invitation" do
expect {
put user_invitation_path, params: params
}.not_to change { volunteer.reload.invitation_accepted_at }
end

it "shows validation error" do
put user_invitation_path, params: params

expect(response).to have_http_status(:success)
expect(response.body).to include("Password confirmation")
end
end

context "with password too short" do
before do
volunteer.invite!
end

let(:params) do
{
user: {
invitation_token: volunteer.raw_invitation_token,
password: "short",
password_confirmation: "short"
}
}
end

it "does not accept the invitation" do
expect {
put user_invitation_path, params: params
}.not_to change { volunteer.reload.invitation_accepted_at }
end

it "shows validation error" do
put user_invitation_path, params: params

expect(response).to have_http_status(:success)
expect(response.body).to include("Password is too short")
end
end

context "with expired invitation" do
before do
volunteer.invite!
travel_to 2.years.from_now
end

after do
travel_back
end

let(:params) do
{
user: {
invitation_token: volunteer.raw_invitation_token,
password: valid_password,
password_confirmation: valid_password
}
}
end

it "does not accept the invitation" do
expect {
put user_invitation_path, params: params
}.not_to change { volunteer.reload.invitation_accepted_at }
end

it "shows validation error" do
put user_invitation_path, params: params

expect(response).to have_http_status(:success)
expect(response.body).to include("Invitation token is invalid")
end
end
end

describe "parameter sanitization" do
before do
volunteer.invite!
end

it "permits invitation_token in update" do
params = {
user: {
invitation_token: volunteer.raw_invitation_token,
password: "Password123!",
password_confirmation: "Password123!",
extra_param: "should_not_be_permitted"
}
}

put user_invitation_path, params: params

# If the invitation_token was properly permitted, the invitation should be accepted
expect(volunteer.reload.invitation_accepted_at).to be_present
end
end
end
55 changes: 55 additions & 0 deletions spec/views/devise/invitations/edit.html.erb_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe "devise/invitations/edit.html.erb", type: :view do
let(:casa_org) { create(:casa_org) }
let(:volunteer) { create(:volunteer, casa_org: casa_org) }

before do
volunteer.invite!
assign(:resource, volunteer)
assign(:resource_name, :user)
assign(:devise_mapping, Devise.mappings[:user])
assign(:minimum_password_length, 6)

# Set the invitation_token on the resource as the controller does
volunteer.invitation_token = volunteer.raw_invitation_token

# Allow the class to respond to require_password_on_accepting
allow(volunteer.class).to receive(:require_password_on_accepting).and_return(true)

render
end

it "uses form_with with local: true" do
# form_with local: true should not have data-remote="true"
expect(rendered).not_to have_selector('form[data-remote="true"]')
end

it "includes invitation_token field" do
expect(rendered).to match(/invitation_token/)
end

it "does not have readonly attribute on invitation_token field" do
expect(rendered).not_to match(/invitation_token.*readonly/)
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern used here is fragile and may produce false negatives. HTML attributes can appear in any order, so a field could have readonly="readonly" before the name attribute, which would not match this pattern. Consider using Capybara's have_field matcher with a readonly option instead, or use a more robust selector like have_selector('input[name*="invitation_token"][readonly]').

Suggested change
expect(rendered).not_to match(/invitation_token.*readonly/)
expect(rendered).to have_field("invitation_token", readonly: false)

Copilot uses AI. Check for mistakes.
end

it "includes password fields" do
expect(rendered).to match(/password/)
expect(rendered).to match(/password_confirmation/)
end

it "includes submit button" do
expect(rendered).to have_button("Set my password")
end

it "uses PUT method" do
expect(rendered).to have_selector('form[method="post"]') # Rails uses POST with _method=put
expect(rendered).to have_field("_method", type: :hidden, with: "put")
end

it "posts to invitation_path" do
expect(rendered).to have_selector("form[action='#{user_invitation_path}']")
end
end
Loading