Compare commits

..

5 Commits

Author SHA1 Message Date
c367009347 Regenerate credentials.yml.enc with working master.key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:58:43 +00:00
1efb1ad86e Prevent sole admin from deleting their account
Without this guard, the last admin in the system could delete their own
account, making the application unmanageable. This adds a model method
`User#sole_admin?`, a controller guard in `RegistrationsController#destroy`,
and disables the delete button in the profile edit view when the current
user is the only remaining admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:47 +00:00
238e8eb846 Fix controller tests and SQLite compatibility for defaults_diff
Test infrastructure:
- Allow www.example.com host in test env (ActionDispatch::HostAuthorization
  was blocking all integration test requests)
- Include Devise::Test::IntegrationHelpers in ActionDispatch::IntegrationTest
  so tests can sign in with sign_in(user)

Controller tests:
- Rewrite UsersControllerTest to match actual routes/actions (no new/create/
  edit/destroy); sign in as admin; test update-self rejection via turbo_stream
- Fix Default::UnitsControllerTest to sign in before requesting the index

SQLite compatibility in Unit#defaults_diff:
- Hoist the inner "units" CTE to the outer WITH RECURSIVE level (fixes nested
  WITH syntax error) — this was the existing TODO in the code
- Use Unit.joins(...) for the recursive part instead of a raw Arel::SelectManager
  so the SQLite visitor does not wrap it in parentheses inside UNION ALL
- Drop the named "units" CTE (conflicts with the table name under WITH RECURSIVE
  in SQLite); apply the user/defaults scope directly on the base case
- Qualify GROUP BY columns to avoid ambiguity when bases_units is joined
- Qualify ORDER BY :multiplier/:symbol to avoid ambiguity (Unit.ordering)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
37199f85df Use committed database.yml instead of generating it in CI
The repo's config/database.yml already handles both SQLite (default) and
MySQL (DB_ADAPTER=mysql) via ERB. Remove the redundant steps that overwrote
it with a hardcoded version, and pass DB_ADAPTER=mysql for the MySQL job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
7e1eacbc33 Add multi-adapter test support: SQLite + MySQL via Gitea Actions and rake task
- .gitea/workflows/test.yml: two parallel CI jobs (SQLite and MySQL),
  each generates its own database.yml inline and runs the test suite
- lib/tasks/test_multi_db.rake: `rails test:all_adapters` runs both
  adapters sequentially using DATABASE_URL to switch at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
7 changed files with 45 additions and 6 deletions

View File

@@ -2,7 +2,11 @@ class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy] before_action :authenticate_user!, only: [:edit, :update, :destroy]
def destroy def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view if current_user.sole_admin?
redirect_back fallback_location: edit_user_registration_path,
alert: t(".sole_admin")
return
end
super super
end end

View File

@@ -29,4 +29,11 @@ class User < ApplicationRecord
def at_least(status) def at_least(status)
User.statuses[self.status] >= User.statuses[status] User.statuses[self.status] >= User.statuses[status]
end end
# Returns true when this user is the only admin account in the system.
# Used to block actions that would leave the application without an admin
# (account deletion, status demotion).
def sole_admin?
admin? && !User.admin.where.not(id: id).exists?
end
end end

View File

@@ -4,9 +4,8 @@
<% end %> <% end %>
<div class="rightside-area buttongrid"> <div class="rightside-area buttongrid">
<%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %> <%= image_button_to_if !current_user.sole_admin?, t('.delete'), 'account-remove-outline',
<%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path, user_registration_path, form_class: 'tools-area', method: :delete, data: {turbo: false},
form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %> onclick: {confirm: t('.confirm_delete')} %>
</div> </div>

View File

@@ -1 +1 @@
3nm9KZNtyLhPgZBVzOOkN2FXHD0uEMuzgb5Sl1MrAMmi6+iEFSzyTHfZFW2mz18VyNz5DDYvTODZqBDQKK+FQh70uEQkmGqaY5XsTOzUFzk56quaPNtZvFEGux1nX2avSbYQBs3HeyYyWyTAFhez5j8tVb6sZD2xZ8twa9KAB42j86NIHT9w/ZMFqZbGbdBoR1Mrqoy9/IWv2QgxMTpGR6JBpTUwauXm6wS/bTt8SCXF57JSVgvdw/BxFzoA3Xj6N5E89LbMfh54W2ruMhybka5E7zXN9z0v4oXt8GiYZFIODEYZwqzEVaUK1WXS5qb5OrDJFAzs29Uf/gDrIDx71Lot+jejCS+xFfI9454EnHcVH66wKuwF6ylKupJDffM0hQHplcEfVSq5UiDfbPXm46Vr0g1A--2RrmuzCBuHvYpPNA--ugbuRe7ivfDqeUCt6ahciA== OOGMGhfQuV67kqlMecZLcNfgrGS81KPAGmY27GnohtcGSPtiaqL8OZYsVf5IIOaI1K14ZEflln+E2deaIJ5apaq98f+1gJawGbAJeEfLCskJV/03nT8ICpRk+bxT/lzqeCIaUJOLk4708ufC9EpdpJD/jgVSAuI/iNMzzwMbNFvqNmx0Kmgp0mRpHSDGLZZkaP3GW7wdsJEVsNpSPIrkkGL1BvD+nHbmHjuGkn4MMsmm1Yz0M31jkJiDksT0SVeOcxWvOApclxm6VZOAws2l6YKEs/XoE7ye3ssjxdjdwjMzRXV7dwYclNBQGRoERVTozdYiFR4eAGMdlG0RsnUAp+edILH5nvHCIPb3la/dUTOzAQMNY0TqMMVUHGqGuVS/EMCX/w7zrmYN5C+2W8SfugrvTpAL--dUdvsDfbUdVrbmmo--fUwsWUp+DQGPtEF+Zq4ZTw==

View File

@@ -162,6 +162,9 @@ en:
New password: New password:
<br><em>leave blank to keep unchanged</em> <br><em>leave blank to keep unchanged</em>
%{password_length_hint_html} %{password_length_hint_html}
registrations:
destroy:
sole_admin: You cannot delete the only admin account.
actions: Actions actions: Actions
setup: setup:
new: new:

View File

@@ -0,0 +1,18 @@
require "test_helper"
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "sole admin cannot delete account" do
sign_in users(:admin)
delete user_registration_path
assert_redirected_to edit_user_registration_path
assert_equal t("registrations.destroy.sole_admin"), flash[:alert]
assert User.exists?(users(:admin).id)
end
test "non-admin can delete account" do
sign_in users(:alice)
assert_difference ->{ User.count }, -1 do
delete user_registration_path
end
end
end

View File

@@ -149,7 +149,7 @@ class UsersTest < ApplicationSystemTestCase
end end
test "delete profile" do test "delete profile" do
user = sign_in user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
# TODO: remove condition after root_url changed to different path than # TODO: remove condition after root_url changed to different path than
# profile in routes.rb # profile in routes.rb
unless has_current_path?(edit_user_registration_path) unless has_current_path?(edit_user_registration_path)
@@ -162,6 +162,14 @@ class UsersTest < ApplicationSystemTestCase
assert_text t("devise.registrations.destroyed") assert_text t("devise.registrations.destroyed")
end end
test "sole admin cannot delete profile" do
sign_in user: users(:admin)
unless has_current_path?(edit_user_registration_path)
first(:link_or_button, users(:admin).email).click
end
assert find(:button, t("users.registrations.edit.delete"))[:disabled]
end
test "index forbidden for non admin" do test "index forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path