diff --git a/app/controllers/entries_controller.rb b/app/controllers/entries_controller.rb index edab4b6c..f3fcbd35 100644 --- a/app/controllers/entries_controller.rb +++ b/app/controllers/entries_controller.rb @@ -1,6 +1,7 @@ class EntriesController < ApplicationController include AvailableContestsConcern - before_action :set_entry, only: %i[ show edit update destroy soft_delete toggle_disqualified modal_details ] + before_action :set_entry, only: %i[ show edit update destroy soft_delete modal_details ] + before_action :set_entry_for_toggle_disqualified, only: %i[ toggle_disqualified ] before_action :set_entry_for_profile, only: %i[ applicant_profile ] before_action :authorize_entry, only: %i[show edit update destroy] before_action :authorize_index, only: [ :index ] @@ -111,7 +112,7 @@ def soft_delete end def toggle_disqualified - authorize @entry + authorize @entry, :toggle_disqualified? @entry.toggle!(:disqualified) redirect_to request.referer || root_path, notice: 'Entry disqualification status has been updated.' end @@ -143,6 +144,12 @@ def set_entry @entry = policy_scope(Entry).find(params[:id]) end + # For toggle_disqualified, find the entry directly and let authorization handle access control + # This ensures container admins can toggle entries even if the scope doesn't include them + def set_entry_for_toggle_disqualified + @entry = Entry.find(params[:id]) + end + # For applicant_profile, we want to find the entry first, then authorize it def set_entry_for_profile @entry = policy_scope(Entry).find(params[:id]) diff --git a/config/environments/staging.rb b/config/environments/staging.rb index b86d6913..51ad6747 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -58,6 +58,9 @@ config.action_mailer.delivery_method = :letter_opener_web config.action_mailer.perform_deliveries = true +# Enable mailer previews (protected by authorization in config/initializers/mailer_previews.rb) + config.action_mailer.show_previews = true + # I18n config.i18n.fallbacks = true diff --git a/spec/controllers/entries_controller_spec.rb b/spec/controllers/entries_controller_spec.rb index f40e9342..c61b0201 100644 --- a/spec/controllers/entries_controller_spec.rb +++ b/spec/controllers/entries_controller_spec.rb @@ -35,4 +35,173 @@ end end end + + describe "PATCH #toggle_disqualified" do + let(:container) { create(:container) } + let(:contest_description) { create(:contest_description, :active, container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } + let(:profile) { create(:profile) } + let(:entry) { create(:entry, profile: profile, contest_instance: contest_instance, disqualified: false) } + + context "when user is a Container Administrator for the container" do + let(:admin_user) { create(:user) } + let(:admin_role) { create(:role, kind: 'Collection Administrator') } + + before do + create(:assignment, user: admin_user, container: container, role: admin_role) + sign_in admin_user + end + + it "toggles the disqualification status from false to true" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.to change { entry.reload.disqualified }.from(false).to(true) + end + + it "toggles the disqualification status from true to false" do + entry.update(disqualified: true) + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.to change { entry.reload.disqualified }.from(true).to(false) + end + + it "redirects to the referer" do + request.env['HTTP_REFERER'] = '/some/path' + patch :toggle_disqualified, params: { id: entry.id } + expect(response).to redirect_to('/some/path') + end + + it "redirects to root path when no referer" do + patch :toggle_disqualified, params: { id: entry.id } + expect(response).to redirect_to(root_path) + end + + it "sets a success notice message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(flash[:notice]).to eq('Entry disqualification status has been updated.') + end + end + + context "when user is a Collection Manager for the container" do + let(:manager_user) { create(:user) } + let(:manager_role) { create(:role, kind: 'Collection Manager') } + + before do + create(:assignment, user: manager_user, container: container, role: manager_role) + sign_in manager_user + end + + it "toggles the disqualification status" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.to change { entry.reload.disqualified }.from(false).to(true) + end + + it "sets a success notice message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(flash[:notice]).to eq('Entry disqualification status has been updated.') + end + end + + context "when user is Axis Mundi" do + let(:axis_mundi_user) { create(:user, :axis_mundi) } + + before do + sign_in axis_mundi_user + end + + it "toggles the disqualification status" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.to change { entry.reload.disqualified }.from(false).to(true) + end + + it "sets a success notice message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(flash[:notice]).to eq('Entry disqualification status has been updated.') + end + end + + context "when user is a Container Administrator for a different container" do + let(:other_container) { create(:container) } + let(:other_admin_user) { create(:user) } + let(:admin_role) { create(:role, kind: 'Collection Administrator') } + + before do + create(:assignment, user: other_admin_user, container: other_container, role: admin_role) + sign_in other_admin_user + end + + it "does not toggle the disqualification status" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.not_to change { entry.reload.disqualified } + end + + it "redirects with unauthorized message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("!!! Not authorized !!!") + end + end + + context "when user is the entry owner" do + before do + sign_in profile.user + end + + it "does not toggle the disqualification status" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.not_to change { entry.reload.disqualified } + end + + it "redirects with unauthorized message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("!!! Not authorized !!!") + end + end + + context "when user is a judge assigned to the contest instance" do + let(:judge_user) { create(:user, :with_judge_role) } + + before do + create(:judging_assignment, user: judge_user, contest_instance: contest_instance) + sign_in judge_user + end + + it "does not toggle the disqualification status" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.not_to change { entry.reload.disqualified } + end + + it "redirects with unauthorized message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("!!! Not authorized !!!") + end + end + + context "when user has no special role" do + let(:regular_user) { create(:user) } + + before do + sign_in regular_user + end + + it "does not toggle the disqualification status" do + expect { + patch :toggle_disqualified, params: { id: entry.id } + }.not_to change { entry.reload.disqualified } + end + + it "redirects with unauthorized message" do + patch :toggle_disqualified, params: { id: entry.id } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("!!! Not authorized !!!") + end + end + end end diff --git a/spec/policies/entry_policy_spec.rb b/spec/policies/entry_policy_spec.rb index 292096c7..a4438937 100644 --- a/spec/policies/entry_policy_spec.rb +++ b/spec/policies/entry_policy_spec.rb @@ -60,4 +60,84 @@ let(:user) { create(:user, :axis_mundi) } it { is_expected.to permit_action(:show) } end + + describe "#toggle_disqualified?" do + let(:container) { contest_instance.contest_description.container } + + context "for a visitor" do + let(:user) { nil } + it { is_expected.to forbid_action(:toggle_disqualified) } + end + + context "for a Container Administrator in the same container" do + let(:user) { create(:user) } + let(:admin_role) { create(:role, kind: 'Collection Administrator') } + + before do + create(:assignment, user: user, container: container, role: admin_role) + end + + it { is_expected.to permit_action(:toggle_disqualified) } + end + + context "for a Collection Manager in the same container" do + let(:user) { create(:user) } + let(:manager_role) { create(:role, kind: 'Collection Manager') } + + before do + create(:assignment, user: user, container: container, role: manager_role) + end + + it { is_expected.to permit_action(:toggle_disqualified) } + end + + context "for Axis Mundi" do + let(:user) { create(:user, :axis_mundi) } + it { is_expected.to permit_action(:toggle_disqualified) } + end + + context "for a Container Administrator in a different container" do + let(:other_container) { create(:container) } + let(:user) { create(:user) } + let(:admin_role) { create(:role, kind: 'Collection Administrator') } + + before do + create(:assignment, user: user, container: other_container, role: admin_role) + end + + it { is_expected.to forbid_action(:toggle_disqualified) } + end + + context "for a Collection Manager in a different container" do + let(:other_container) { create(:container) } + let(:user) { create(:user) } + let(:manager_role) { create(:role, kind: 'Collection Manager') } + + before do + create(:assignment, user: user, container: other_container, role: manager_role) + end + + it { is_expected.to forbid_action(:toggle_disqualified) } + end + + context "for the entry owner" do + let(:user) { profile.user } + it { is_expected.to forbid_action(:toggle_disqualified) } + end + + context "for a judge assigned to the contest instance" do + let(:user) { create(:user, :with_judge_role) } + + before do + create(:judging_assignment, user: user, contest_instance: contest_instance) + end + + it { is_expected.to forbid_action(:toggle_disqualified) } + end + + context "for a regular user with no special role" do + let(:user) { create(:user) } + it { is_expected.to forbid_action(:toggle_disqualified) } + end + end end diff --git a/yarn.lock b/yarn.lock index 6333b99b..35d5af1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3891,9 +3891,9 @@ undici-types@~6.20.0: integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== undici@^6.16.1: - version "6.21.3" - resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a" - integrity sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw== + version "6.23.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.23.0.tgz#7953087744d9095a96f115de3140ca3828aff3a4" + integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1"