Use a state machine for user status

The user status is a bit complex, since there are various states and
not all transitions between them make sense.

Using AASM means that we can name and restrict the transitions, which
hopefully makes them easier to reason about.
This commit is contained in:
Andy Allan 2022-01-05 18:44:46 +00:00
parent 786f28993a
commit 1a11c4dc19
12 changed files with 103 additions and 45 deletions

View file

@ -25,7 +25,7 @@ class ConfirmationsController < ApplicationController
render_unknown_user token.user.display_name render_unknown_user token.user.display_name
else else
user = token.user user = token.user
user.status = "active" user.activate
user.email_valid = true user.email_valid = true
flash[:notice] = gravatar_status_message(user) if gravatar_enable(user) flash[:notice] = gravatar_status_message(user) if gravatar_enable(user)
user.save! user.save!

View file

@ -46,7 +46,7 @@ class PasswordsController < ApplicationController
if params[:user] if params[:user]
current_user.pass_crypt = params[:user][:pass_crypt] current_user.pass_crypt = params[:user][:pass_crypt]
current_user.pass_crypt_confirmation = params[:user][:pass_crypt_confirmation] current_user.pass_crypt_confirmation = params[:user][:pass_crypt_confirmation]
current_user.status = "active" if current_user.status == "pending" current_user.activate if current_user.may_activate?
current_user.email_valid = true current_user.email_valid = true
if current_user.save if current_user.save

View file

@ -164,8 +164,6 @@ class UsersController < ApplicationController
Rails.logger.info "create: #{session[:referer]}" Rails.logger.info "create: #{session[:referer]}"
current_user.status = "pending"
if current_user.auth_provider.present? && current_user.pass_crypt.empty? if current_user.auth_provider.present? && current_user.pass_crypt.empty?
# We are creating an account with external authentication and # We are creating an account with external authentication and
# no password was specified so create a random one # no password was specified so create a random one
@ -202,15 +200,17 @@ class UsersController < ApplicationController
## ##
# sets a user's status # sets a user's status
def set_status def set_status
@user.status = params[:status] @user.activate! if params[:event] == "activate"
@user.save @user.confirm! if params[:event] == "confirm"
@user.hide! if params[:event] == "hide"
@user.unhide! if params[:event] == "unhide"
redirect_to user_path(:display_name => params[:display_name]) redirect_to user_path(:display_name => params[:display_name])
end end
## ##
# destroy a user, marking them as deleted and removing personal data # destroy a user, marking them as deleted and removing personal data
def destroy def destroy
@user.destroy @user.soft_destroy!
redirect_to user_path(:display_name => params[:display_name]) redirect_to user_path(:display_name => params[:display_name])
end end

View file

@ -45,6 +45,7 @@
class User < ApplicationRecord class User < ApplicationRecord
require "digest" require "digest"
include AASM
has_many :traces, -> { where(:visible => true) } has_many :traces, -> { where(:visible => true) }
has_many :diary_entries, -> { order(:created_at => :desc) } has_many :diary_entries, -> { order(:created_at => :desc) }
@ -158,6 +159,51 @@ class User < ApplicationRecord
user user
end end
aasm :column => :status, :no_direct_assignment => true do
state :pending, :initial => true
state :active
state :confirmed
state :suspended
state :deleted
# A normal account is active
event :activate do
transitions :from => :pending, :to => :active
end
# Used in test suite, not something that we would normally need to do.
event :deactivate do
transitions :from => :active, :to => :pending
end
# To confirm an account is used to override the spam scoring
event :confirm do
transitions :from => [:pending, :active, :suspended], :to => :confirmed
end
event :suspend do
transitions :from => [:pending, :active], :to => :suspended
end
# Mark the account as deleted but keep all data intact
event :hide do
transitions :from => [:pending, :active, :confirmed, :suspended], :to => :deleted
end
event :unhide do
transitions :from => [:deleted], :to => :active
end
# Mark the account as deleted and remove personal data
event :soft_destroy do
before do
remove_personal_data
end
transitions :from => [:pending, :active, :confirmed, :suspended], :to => :deleted
end
end
def description def description
RichText.new(self[:description_format], self[:description]) RichText.new(self[:description_format], self[:description])
end end
@ -241,8 +287,8 @@ class User < ApplicationRecord
end end
## ##
# destroy a user - leave the account but purge most personal data # remove personal data - leave the account but purge most personal data
def destroy def remove_personal_data
avatar.purge_later avatar.purge_later
self.display_name = "user_#{id}" self.display_name = "user_#{id}"
@ -253,7 +299,6 @@ class User < ApplicationRecord
self.new_email = nil self.new_email = nil
self.auth_provider = nil self.auth_provider = nil
self.auth_uid = nil self.auth_uid = nil
self.status = "deleted"
save save
end end
@ -279,7 +324,7 @@ class User < ApplicationRecord
## ##
# perform a spam check on a user # perform a spam check on a user
def spam_check def spam_check
update(:status => "suspended") if status == "active" && spam_score > Settings.spam_threshold suspend! if may_suspend? && spam_score > Settings.spam_threshold
end end
## ##

View file

@ -139,30 +139,32 @@
<nav class='secondary-actions'> <nav class='secondary-actions'>
<ul class='clearfix'> <ul class='clearfix'>
<% if can? :set_status, User %> <% if can? :set_status, User %>
<% if ["active", "confirmed"].include? @user.status %> <% if @user.may_activate? %>
<li> <li>
<%= link_to t(".deactivate_user"), set_status_user_path(:status => "pending", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %> <%= link_to t(".activate_user"), set_status_user_path(:event => "activate", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
</li>
<% elsif ["pending"].include? @user.status %>
<li>
<%= link_to t(".activate_user"), set_status_user_path(:status => "active", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
</li> </li>
<% end %> <% end %>
<% if ["active", "suspended"].include? @user.status %> <% if @user.may_confirm? %>
<li> <li>
<%= link_to t(".confirm_user"), set_status_user_path(:status => "confirmed", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %> <%= link_to t(".confirm_user"), set_status_user_path(:event => "confirm", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
</li>
<% end %>
<% if @user.may_hide? %>
<li>
<%= link_to t(".hide_user"), set_status_user_path(:event => "hide", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
</li>
<% end %>
<% if @user.may_unhide? %>
<li>
<%= link_to t(".unhide_user"), set_status_user_path(:event => "unhide", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
</li> </li>
<% end %> <% end %>
<li>
<% if ["pending", "active", "confirmed", "suspended"].include? @user.status %>
<%= link_to t(".hide_user"), set_status_user_path(:status => "deleted", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
<% else %>
<%= link_to t(".unhide_user"), set_status_user_path(:status => "active", :display_name => @user.display_name), :method => :post, :data => { :confirm => t(".confirm") } %>
<% end %>
</li>
<% end %> <% end %>
<% if can? :destroy, User %>
<% if can?(:destroy, User) && @user.may_soft_destroy? %>
<li> <li>
<%= link_to t(".delete_user"), user_path(:display_name => @user.display_name), :method => :delete, :data => { :confirm => t(".confirm") } %> <%= link_to t(".delete_user"), user_path(:display_name => @user.display_name), :method => :delete, :data => { :confirm => t(".confirm") } %>
</li> </li>

View file

@ -137,7 +137,7 @@ class BrowseControllerTest < ActionDispatch::IntegrationTest
end end
def test_read_note_hidden_user_comment def test_read_note_hidden_user_comment
hidden_user = create(:user, :status => "deleted") hidden_user = create(:user, :deleted)
note_with_hidden_user_comment = create(:note_with_comments, :comments_count => 2) do |note| note_with_hidden_user_comment = create(:note_with_comments, :comments_count => 2) do |note|
create(:note_comment, :note => note, :author => hidden_user) create(:note_comment, :note => note, :author => hidden_user)
end end
@ -161,7 +161,7 @@ class BrowseControllerTest < ActionDispatch::IntegrationTest
assert_select "div.note-comments ul li", :count => 2 assert_select "div.note-comments ul li", :count => 2
assert_select "div.details", /Resolved by #{user.display_name}/ assert_select "div.details", /Resolved by #{user.display_name}/
user.destroy user.soft_destroy!
reset! reset!

View file

@ -185,7 +185,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
post user_save_path, :params => { :read_ct => 1, :read_tou => 1 } post user_save_path, :params => { :read_ct => 1, :read_tou => 1 }
confirm_string = User.find_by(:email => user.email).tokens.create.token confirm_string = User.find_by(:email => user.email).tokens.create.token
User.find_by(:display_name => user.display_name).update(:status => "deleted") User.find_by(:display_name => user.display_name).hide!
# Get the confirmation page # Get the confirmation page
get user_confirm_path, :params => { :display_name => user.display_name, :confirm_string => confirm_string } get user_confirm_path, :params => { :display_name => user.display_name, :confirm_string => confirm_string }
@ -242,7 +242,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
post user_new_path, :params => { :user => user.attributes } post user_new_path, :params => { :user => user.attributes }
post user_save_path, :params => { :read_ct => 1, :read_tou => 1 } post user_save_path, :params => { :read_ct => 1, :read_tou => 1 }
User.find_by(:display_name => user.display_name).update(:status => "deleted") User.find_by(:display_name => user.display_name).hide!
assert_no_difference "ActionMailer::Base.deliveries.size" do assert_no_difference "ActionMailer::Base.deliveries.size" do
perform_enqueued_jobs do perform_enqueued_jobs do

View file

@ -547,21 +547,21 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
user = create(:user) user = create(:user)
# Try without logging in # Try without logging in
post set_status_user_path(user), :params => { :status => "suspended" } post set_status_user_path(user), :params => { :event => "confirm" }
assert_response :forbidden assert_response :forbidden
# Now try as a normal user # Now try as a normal user
session_for(user) session_for(user)
post set_status_user_path(user), :params => { :status => "suspended" } post set_status_user_path(user), :params => { :event => "confirm" }
assert_response :redirect assert_response :redirect
assert_redirected_to :controller => :errors, :action => :forbidden assert_redirected_to :controller => :errors, :action => :forbidden
# Finally try as an administrator # Finally try as an administrator
session_for(create(:administrator_user)) session_for(create(:administrator_user))
post set_status_user_path(user), :params => { :status => "suspended" } post set_status_user_path(user), :params => { :event => "confirm" }
assert_response :redirect assert_response :redirect
assert_redirected_to :action => :show, :display_name => user.display_name assert_redirected_to :action => :show, :display_name => user.display_name
assert_equal "suspended", User.find(user.id).status assert_equal "confirmed", User.find(user.id).status
end end
def test_destroy def test_destroy

View file

@ -6,7 +6,10 @@ FactoryBot.define do
# These attributes are not the defaults, but in most tests we want # These attributes are not the defaults, but in most tests we want
# a 'normal' user who can log in without being redirected etc. # a 'normal' user who can log in without being redirected etc.
status { "active" } after(:build) do |user, _evaluator|
user.activate
end
terms_seen { true } terms_seen { true }
terms_agreed { Time.now.getutc } terms_agreed { Time.now.getutc }
data_public { true } data_public { true }
@ -17,23 +20,31 @@ FactoryBot.define do
end end
trait :pending do trait :pending do
status { "pending" } after(:build) do |user, _evaluator|
user.deactivate
end
end end
trait :active do trait :active do
status { "active" } # status { "active" }
end end
trait :confirmed do trait :confirmed do
status { "confirmed" } after(:build) do |user, _evaluator|
user.confirm
end
end end
trait :suspended do trait :suspended do
status { "suspended" } after(:build) do |user, _evaluator|
user.suspend
end
end end
trait :deleted do trait :deleted do
status { "deleted" } after(:build) do |user, _evaluator|
user.soft_destroy
end
end end
factory :moderator_user do factory :moderator_user do

View file

@ -246,9 +246,9 @@ class UserTest < ActiveSupport::TestCase
assert create(:moderator_user).has_role?("moderator") assert create(:moderator_user).has_role?("moderator")
end end
def test_destroy def test_soft_destroy
user = create(:user, :with_home_location, :description => "foo") user = create(:user, :with_home_location, :description => "foo")
user.destroy user.soft_destroy
assert_equal "user_#{user.id}", user.display_name assert_equal "user_#{user.id}", user.display_name
assert user.description.blank? assert user.description.blank?
assert_nil user.home_lat assert_nil user.home_lat

View file

@ -35,7 +35,7 @@ class DiaryEntrySystemTest < ApplicationSystemTestCase
end end
test "deleted diary entries should not be shown to admins when the user is also deleted" do test "deleted diary entries should not be shown to admins when the user is also deleted" do
@deleted_user = create(:user, :status => :deleted) @deleted_user = create(:user, :deleted)
@deleted_entry = create(:diary_entry, :visible => false, :user => @deleted_user) @deleted_entry = create(:diary_entry, :visible => false, :user => @deleted_user)
sign_in_as(create(:administrator_user)) sign_in_as(create(:administrator_user))

View file

@ -7,7 +7,7 @@ class UserSuspensionTest < ApplicationSystemTestCase
visit edit_account_path visit edit_account_path
assert_content "My Settings" assert_content "My Settings"
user.update(:status => "suspended") user.suspend!
visit edit_account_path visit edit_account_path
assert_content "This decision will be reviewed by an administrator shortly" assert_content "This decision will be reviewed by an administrator shortly"