diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb new file mode 100644 index 0000000000..d2026c64e2 --- /dev/null +++ b/app/controllers/users/invitations_controller.rb @@ -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 diff --git a/app/views/devise/invitations/edit.html.erb b/app/views/devise/invitations/edit.html.erb index 637cbed940..24d5348055 100644 --- a/app/views/devise/invitations/edit.html.erb +++ b/app/views/devise/invitations/edit.html.erb @@ -1,9 +1,9 @@

Send invitation

- <%= 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 %>
diff --git a/config/routes.rb b/config/routes.rb index 6f849a7f53..e486ce17cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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) diff --git a/spec/requests/users/invitations_controller_spec.rb b/spec/requests/users/invitations_controller_spec.rb new file mode 100644 index 0000000000..eb403449e7 --- /dev/null +++ b/spec/requests/users/invitations_controller_spec.rb @@ -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) + 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 diff --git a/spec/views/devise/invitations/edit.html.erb_spec.rb b/spec/views/devise/invitations/edit.html.erb_spec.rb new file mode 100644 index 0000000000..a01a7bb57e --- /dev/null +++ b/spec/views/devise/invitations/edit.html.erb_spec.rb @@ -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/) + 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