From d0f8f44f897a222fe88c57386daba5faa4db61ed Mon Sep 17 00:00:00 2001
From: Splines <37160523+Splines@users.noreply.github.com>
Date: Tue, 13 Aug 2024 23:28:08 +0200
Subject: [PATCH] Delete users that haven't logged in for too long (#647)
* Add deletion_date to users in DB
* Set deletion date in user cleaner
* Remove old user cleaner code & add first unit test
* Don't just mount `spec` folder, but complete `app` folder for tests
This is to ensure that local changes in the `app` folder are reflected
in the Docker container such that newly written tests can be run through
without having to rebuild the whole Docker image.
* Revert "Don't just mount `spec` folder, but complete `app` folder for tests"
This reverts commit b3f3ec800c8d23b6ce87e9c93da0111b046b1bde.
* Add more unit tests for UserCleaner
* Improve tests for deletion of generic users only
* Split test for deletion of users
* Unset deletion date after successful user login
* Implement un-setting the deletion date & add tests
* Rewrite deletion unsetting to reuse existing method
* Send initial warning mail about deletion in 40 days
* Send additional warning mails when deletion date is near
* Add schema comment pointing to UserCleaner
* Test mail delivery
* Add "!" to unsafe method
* Use Rails logger to log info about deleted users
* Move UserCleaner from models to workers
* Setup user cleaner cron job to run daily
* Improve user cleaner doc strings & refactor
* Remove user ghost hash remnants
* Avoid unnecessary `to_date` conversion
* Improve comment for `after_authentication` hook
* Fix wording in comment
* Make better use of `let` RSpec keyword
* Limit the number of users deleted in one run
* Simplify unsetting deletion date logic
* Define `active_users` to reduce SQL operations
* Use `7.1` as Rails version specifier in migrations
* Explicitly set `INACTIVE_USER_THRESHOLD` in tests
* Make `MAX_DELETIONS_PER_RUN` an env variable
* Don't send deletion warning mails to non-generic users
* Send a final deletion mail once the user is deleted
* Move user cleaner file back to models folder
* Rename user cleaner files
* Count users without last_sign_in_at date as inactive
* Improve docstring for `inactive_users`
* Shorten user cleaner comment in docker.env
* Introduce `PRODUCTION_NAME` env variable
* Don't run user cleaner for mampf-experimental/dev
* Rewrite logic for when user cleaner is started
* Fix rubocop "where range"
* Try to use after instead of append_after
* Revert "Try to use after instead of append_after"
This reverts commit 90aa6621be350c09c32ad3b903122500b1d37da4.
* Try to not use cached docker in CI/CD unit tests
* Revert "Try to not use cached docker in CI/CD unit tests"
This reverts commit 069032b2e4cd5150d90806c0ca1e752e47d6a11e.
* Move transaction strategy to before each block
* Use around each in database cleaner config
* Use version 2.1.0 of database cleaner
* Revert "Use version 2.1.0 of database cleaner"
This reverts commit 6dfcd9694d6a4c2d29a38a0394aaa3823a591b4d.
* Add missing require "rails_helper"
* Try deletion strategy of database cleaner
---
app/mailers/mathi_mailer.rb | 8 -
app/mailers/user_cleaner_mailer.rb | 28 ++
app/models/user_cleaner.rb | 219 +++++++------
.../layouts/warning_mail_layout.html.erb | 18 ++
app/views/mathi_mailer/ghost_email.html.erb | 1 -
.../deletion_email.html.erb | 10 +
.../pending_deletion_email.html.erb | 12 +
app/workers/user_cleaner.rb | 13 +
app/workers/user_cleaner_job.rb | 8 -
config/environments/production.rb | 5 +
config/initializers/after_sign_in.rb | 10 +
config/locales/de.yml | 16 +
config/locales/en.yml | 15 +
config/schedule.yml | 7 +-
...0240530200000_add_deletion_date_to_user.rb | 5 +
...40605200000_remove_ghost_hash_from_user.rb | 5 +
db/schema.rb | 4 +-
docker/development/docker-compose.yml | 1 +
.../production/docker-compose.production.yml | 2 +
docker/production/docker.env | 3 +
docker/test/docker-compose.yml | 1 +
spec/factories/user_cleaners.rb | 12 -
spec/models/teachable_parser_spec.rb | 2 +
spec/models/user_cleaner_spec.rb | 293 +++++++++++++++++-
spec/rails_helper.rb | 4 +
25 files changed, 552 insertions(+), 150 deletions(-)
create mode 100644 app/mailers/user_cleaner_mailer.rb
create mode 100644 app/views/layouts/warning_mail_layout.html.erb
delete mode 100644 app/views/mathi_mailer/ghost_email.html.erb
create mode 100644 app/views/user_cleaner_mailer/deletion_email.html.erb
create mode 100644 app/views/user_cleaner_mailer/pending_deletion_email.html.erb
create mode 100644 app/workers/user_cleaner.rb
delete mode 100644 app/workers/user_cleaner_job.rb
create mode 100644 config/initializers/after_sign_in.rb
create mode 100644 db/migrate/20240530200000_add_deletion_date_to_user.rb
create mode 100644 db/migrate/20240605200000_remove_ghost_hash_from_user.rb
delete mode 100644 spec/factories/user_cleaners.rb
diff --git a/app/mailers/mathi_mailer.rb b/app/mailers/mathi_mailer.rb
index de9a71172..f53efa572 100644
--- a/app/mailers/mathi_mailer.rb
+++ b/app/mailers/mathi_mailer.rb
@@ -2,14 +2,6 @@ class MathiMailer < ApplicationMailer
default from: DefaultSetting::PROJECT_EMAIL
layout false
- def ghost_email(user)
- return if user.ghost_hash.nil?
-
- @name = user.name
- @hash = user.ghost_hash
- mail(to: user.email, subject: t("mailer.hash_mail_subject"))
- end
-
def data_request_email(user)
@mail = user.email
@id = user.id
diff --git a/app/mailers/user_cleaner_mailer.rb b/app/mailers/user_cleaner_mailer.rb
new file mode 100644
index 000000000..f8535ccc2
--- /dev/null
+++ b/app/mailers/user_cleaner_mailer.rb
@@ -0,0 +1,28 @@
+class UserCleanerMailer < ApplicationMailer
+ layout "warning_mail_layout"
+
+ # Creates an email to inform a user that their account will be deleted.
+ #
+ # @param [Integer] num_days_until_deletion:
+ # The number of days until the account will be deleted.
+ def pending_deletion_email(num_days_until_deletion)
+ user = params[:user]
+ sender = "#{t("mailer.warning")} <#{DefaultSetting::PROJECT_EMAIL}>"
+ I18n.locale = user.locale
+
+ @num_days_until_deletion = num_days_until_deletion
+ subject = t("mailer.pending_deletion_subject",
+ num_days_until_deletion: @num_days_until_deletion)
+ mail(from: sender, to: user.email, subject: subject, priority: "high")
+ end
+
+ # Creates an email to inform a user that their account has been deleted.
+ def deletion_email
+ user = params[:user]
+ sender = "#{t("mailer.warning")} <#{DefaultSetting::PROJECT_EMAIL}>"
+ I18n.locale = user.locale
+
+ subject = t("mailer.deletion_subject")
+ mail(from: sender, to: user.email, subject: subject, priority: "high")
+ end
+end
diff --git a/app/models/user_cleaner.rb b/app/models/user_cleaner.rb
index 8a5763e53..a70bb8f7f 100644
--- a/app/models/user_cleaner.rb
+++ b/app/models/user_cleaner.rb
@@ -1,125 +1,140 @@
-# PORO class that removes users with inactive emails
+# Deletes inactive users from the database.
+# See [1] for a description of how the flow works on a high level.
+#
+# Users have a deletion_date field that is nil by default. It is set to a future
+# date if the user has been inactive for too long (i.e. hasn't logged in).
+# Before the deletion date is reached, we send warning mails. If users log in
+# before the deletion date, that date is reset to nil such that the user is not
+# deleted. If the user is still inactive on the deletion date, the user is
+# ultimately deleted.
+#
+# [1] https://github.com/MaMpf-HD/mampf/issues/410#issuecomment-2136875776
class UserCleaner
- attr_accessor :imap, :email_dict, :hash_dict
-
- def login
- @imap = Net::IMAP.new(ENV.fetch("IMAPSERVER"), port: 993, ssl: true)
- @imap.authenticate("LOGIN", ENV.fetch("PROJECT_EMAIL_USERNAME"),
- ENV.fetch("PROJECT_EMAIL_PASSWORD"))
+ # The maximum number of users that can be deleted in one run.
+ # This is equivalent to the maximum of number of deletion dates set in one run.
+ #
+ # This flag can be used to prevent too many mails from being sent at once.
+ # Keep in mind that the mail server also handles other mails, e.g. notification
+ # mails etc., so we might want to set the limit very low here such that our
+ # mail server is not marked as "spam server".
+ #
+ # Note that this is just a soft limit, i.e. the actual number of deletion
+ # warning mails sent on a given day might be higher than this number:
+ # - If on a given day the cronjob is not run (for whatever reason),
+ # we have more users with a deletion date lying in the past than
+ # MAX_DELETIONS_PER_RUN. However, we don't send an additional mail once
+ # the user is deleted, so this shouldn't be a problem.
+ # - Despite that there cannot be more than MAX_DELETIONS_PER_RUN users with the
+ # same deletion date, warning mails might be sent on the same date to users
+ # with varying deletion dates, since the 40-, 14-, 7- and 2-day warning mails
+ # can overlap temporally.
+ MAX_DELETIONS_PER_RUN = ENV.fetch("MAX_DELETIONS_PER_RUN").to_i
+
+ # The threshold for inactive users. Users who have not logged in for this time
+ # are considered inactive.
+ INACTIVE_USER_THRESHOLD = 6.months
+
+ # Returns all users who have been inactive for INACTIVE_USER_THRESHOLD months,
+ # i.e. their last sign-in date is more than INACTIVE_USER_THRESHOLD months ago.
+ #
+ # Users without a last_sign_in_at date are also considered inactive. This is
+ # the case for users who have never logged in since PR #553 was merged.
+ def inactive_users
+ User.where(last_sign_in_at: ...INACTIVE_USER_THRESHOLD.ago)
+ .or(User.where(last_sign_in_at: nil))
end
- def logout
- @imap.logout
+ # Returns all users who have been active in the last INACTIVE_USER_THRESHOLD months,
+ # i.e. their last sign-in date is less than INACTIVE_USER_THRESHOLD months ago.
+ def active_users
+ User.where(last_sign_in_at: INACTIVE_USER_THRESHOLD.ago..)
end
- def search_emails_and_hashes
- @email_dict = {}
- @hash_dict = {}
- @imap.examine(ENV.fetch("PROJECT_EMAIL_MAILBOX"))
- # Mails containing multiple email addresses (Subject: "Undelivered Mail Returned to Sender")
- @imap.search(["SUBJECT",
- "Undelivered Mail Returned to Sender"]).each do |message_id|
- body = @imap.fetch(message_id,
- "BODY[TEXT]")[0].attr["BODY[TEXT]"].squeeze(" ")
- # rubocop:disable Layout/LineLength
- next unless (match = body.scan(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4})[\s\S]*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4})[\s\S]*?User has moved to ERROR: Account expired/))
- # rubocop:enable Layout/LineLength
-
- match = match.flatten.uniq
- match.each do |email|
- add_mail(email, message_id)
-
- try_get_hash(body, email)
- end
- end
- # Mails containing single email addresses (Subject: "Delivery Status Notification (Failure)")
- # define array containing all used regex patterns
- patterns = [
- '([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4})>[\s\S]*?Unknown recipient',
- # rubocop:disable Layout/LineLength
- '([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4})>[\s\S]*?User unknown in virtual mailbox table'
- # rubocop:enable Layout/LineLength
- ]
-
- @imap.search(["SUBJECT",
- "Delivery Status Notification (Failure)"]).each do |message_id|
- body = @imap.fetch(message_id,
- "BODY[TEXT]")[0].attr["BODY[TEXT]"].squeeze(" ")
- patterns.each do |pattern|
- next unless (match = body.scan(/#{pattern}/))
-
- match = match.flatten.uniq
- match.each do |email|
- add_mail(email, message_id)
-
- try_get_hash(body, email)
- end
+ # Sets the deletion date for inactive users and sends an initial warning mail.
+ #
+ # This method finds all inactive users whose deletion date is nil (not set yet)
+ # and updates their deletion date to be 40 days from the current date.
+ #
+ # The maximum number of deletion dates set in one run is limited by
+ # MAX_DELETIONS_PER_RUN.
+ def set_deletion_date_for_inactive_users
+ inactive_users.where(deletion_date: nil)
+ .limit(MAX_DELETIONS_PER_RUN)
+ .find_each do |user|
+ user.update(deletion_date: Date.current + 40.days)
+
+ if user.generic? # rubocop:disable Style/IfUnlessModifier
+ UserCleanerMailer.with(user: user).pending_deletion_email(40).deliver_later
end
end
end
- def add_mail(email, message_id)
- @email_dict = {} if @email_dict.blank?
- if @email_dict.key?(email)
- @email_dict[email] << message_id
- else
- @email_dict[email] = [message_id]
- end
+ # Unsets the deletion date for users who have been active recently.
+ #
+ # This method finds all users whose deletion date is set and unsets it if the
+ # user has been active recently.
+ #
+ # Note that this method just serves as a safety measure. The deletion date
+ # should be unset after every successful user sign-in, see the Warden callback
+ # in `config/initializers/after_sign_in.rb`. If for some reason, the callback
+ # does not work, this method will prevent active users from being deleted
+ # as a last resort.
+ def unset_deletion_date_for_recently_active_users
+ active_users.where.not(deletion_date: nil).update(deletion_date: nil)
end
- def try_get_hash(body, email)
- @hash_dict = {} if @hash_dict.blank?
- begin
- hash = body.match(/Hash:([a-zA-Z0-9]*)/).captures
- @hash_dict[email] = hash
- rescue StandardError
- nil
+ # Deletes all users whose deletion date is in the past or present.
+ #
+ # Technically, there should never be users with a deletion date in the past
+ # since the cron job is run daily and should delete users on the day of their
+ # deletion date. Should the cron job not run for some reason, we also delete
+ # users with a deletion date in the past via this method.
+ #
+ # The deletion date for the users must have been set beforehand by calling
+ # `set_deletion_date_for_inactive_users`.
+ #
+ # Even after having called this method, there might still exist users with a
+ # deletion date in the future, as we only delete generic users.
+ def delete_users_according_to_deletion_date!
+ num_deleted_users = 0
+
+ User.where(deletion_date: ..Date.current).find_each do |user|
+ next unless user.generic?
+
+ UserCleanerMailer.with(user: user).deletion_email.deliver_later
+ user.destroy
+ num_deleted_users += 1
end
- end
- def send_hashes
- @emails = @email_dict.keys
- @users = User.where(email: @emails)
-
- @users.each do |user|
- user.update(ghost_hash: Digest::SHA256.hexdigest(Time.now.to_i.to_s))
- MathiMailer.ghost_email(user).deliver_now
- move_mail(@email_dict[user])
- end
+ Rails.logger.info("UserCleaner deleted #{num_deleted_users} stale users")
end
- def delete_ghosts
- # @hash_dict.each do |mail, hash|
- # u = User.find_by(email: mail, ghost_hash: hash)
- # move_mail(@email_dict[mail]) if u.present? && @email_dict.present?
- # u.destroy! if u&.generic?
- # end
- end
-
- def move_mail(message_ids, attempt = 0)
- return if message_ids.blank?
+ # Sends additional warning mails to users whose deletion date is near.
+ #
+ # In addition to the initial warning mail 40 days before deletion, this method
+ # sends warning mails 14, 7 and 2 days before the account is deleted.
+ def send_additional_warning_mails
+ User.where.not(deletion_date: nil).find_each do |user|
+ next unless user.generic?
- message_ids = Array(message_ids)
- return if attempt > 3
+ num_days_until_deletion = (user.deletion_date - Date.current).to_i
- begin
- @imap.examine(ENV.fetch("PROJECT_EMAIL_MAILBOX"))
- @imap.move(message_ids, "Other Users/mampf/handled_bounces")
- rescue Net::IMAP::BadResponseError
- move_mail(message_ids, attempt + 1)
+ if [14, 7, 2].include?(num_days_until_deletion)
+ UserCleanerMailer.with(user: user)
+ .pending_deletion_email(num_days_until_deletion)
+ .deliver_later
+ end
end
end
- def clean!
- # TODO: Implement new user cleaner logic
- # login
- # search_emails_and_hashes
- # return if @email_dict.blank?
+ # Handles inactive users according to the deletion policy documented
+ # in the UserCleaner class description. Brief: users that haven't logged in
+ # to MaMpf for too long will be deleted.
+ def handle_inactive_users!
+ set_deletion_date_for_inactive_users
+ unset_deletion_date_for_recently_active_users
+ delete_users_according_to_deletion_date!
- # send_hashes
- # sleep(10)
- # search_emails_and_hashes
- # delete_ghosts
- # logout
+ send_additional_warning_mails
end
end
diff --git a/app/views/layouts/warning_mail_layout.html.erb b/app/views/layouts/warning_mail_layout.html.erb
new file mode 100644
index 000000000..9ae0d3135
--- /dev/null
+++ b/app/views/layouts/warning_mail_layout.html.erb
@@ -0,0 +1,18 @@
+
+
+
+
+
+ <%= stylesheet_link_tag :email %>
+
+
+
+
+ <%= email_image_tag('/MaMpf-Logo_96x96.png', class: 'img-fluid mb-2 pt-4 pb-3' ) %>
+
+ <%= yield %>
+
+
+
+
+
diff --git a/app/views/mathi_mailer/ghost_email.html.erb b/app/views/mathi_mailer/ghost_email.html.erb
deleted file mode 100644
index ac7358de3..000000000
--- a/app/views/mathi_mailer/ghost_email.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-<%= t('mailer.hash_mail_body', name: @name, hash: @hash) %>
\ No newline at end of file
diff --git a/app/views/user_cleaner_mailer/deletion_email.html.erb b/app/views/user_cleaner_mailer/deletion_email.html.erb
new file mode 100644
index 000000000..ead872baf
--- /dev/null
+++ b/app/views/user_cleaner_mailer/deletion_email.html.erb
@@ -0,0 +1,10 @@
+
+
+
+ <%= t('mailer.deletion_subject') %>
+
+
+ <%= t('mailer.deletion_body_html') %>
+
+
+
diff --git a/app/views/user_cleaner_mailer/pending_deletion_email.html.erb b/app/views/user_cleaner_mailer/pending_deletion_email.html.erb
new file mode 100644
index 000000000..ad98f7ee5
--- /dev/null
+++ b/app/views/user_cleaner_mailer/pending_deletion_email.html.erb
@@ -0,0 +1,12 @@
+
+
+
+ <%= t('mailer.pending_deletion_subject',
+ num_days_until_deletion: @num_days_until_deletion) %>
+
+
+ <%= t('mailer.pending_deletion_body_html',
+ num_days_until_deletion: @num_days_until_deletion) %>
+
+
+
diff --git a/app/workers/user_cleaner.rb b/app/workers/user_cleaner.rb
new file mode 100644
index 000000000..184a21f5d
--- /dev/null
+++ b/app/workers/user_cleaner.rb
@@ -0,0 +1,13 @@
+class UserCleanerJob
+ include Sidekiq::Worker
+
+ def perform
+ # Only run this job in production, not for mampf-experimental or mampf-dev.
+ # Note that Rails.env.production? is not sufficient in this context
+ # as both mampf-experimental and mampf-dev also run in production mode.
+ production_name = ENV.fetch("PRODUCTION_NAME", nil)
+ return unless production_name == "mampf"
+
+ UserCleaner.new.handle_inactive_users!
+ end
+end
diff --git a/app/workers/user_cleaner_job.rb b/app/workers/user_cleaner_job.rb
deleted file mode 100644
index 4c20eb572..000000000
--- a/app/workers/user_cleaner_job.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-class UserCleanerJob
- include Sidekiq::Worker
-
- def perform
- user_cleaner = UserCleaner.new
- user_cleaner.clean!
- end
-end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 18e070a38..7f0f4cec4 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -78,4 +78,9 @@
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
+
+ config.after_initialize do
+ production_name = ENV.fetch("PRODUCTION_NAME", nil)
+ Rails.logger.info("PRODUCTION_NAME: #{production_name}")
+ end
end
diff --git a/config/initializers/after_sign_in.rb b/config/initializers/after_sign_in.rb
new file mode 100644
index 000000000..8d6a8c694
--- /dev/null
+++ b/config/initializers/after_sign_in.rb
@@ -0,0 +1,10 @@
+# Callback documentation for Warden (used by Devise):
+# https://github.com/wardencommunity/warden/wiki/Callbacks#after_authentication
+Warden::Manager.after_authentication do |user, _auth, _opts|
+ # User might have not logged in for a long time, which is why they have a
+ # deletion date set. If the user logs in, we unset this date to prevent the
+ # user from being deleted. See the UserCleaner class for more information.
+ # In case this callback does not work, a safety net is provided by the method
+ # `unset_deletion_date_for_recently_active_users` in the UserCleaner class.
+ user.deletion_date = nil
+end
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 7bf8811e8..fa4875bc7 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -3053,6 +3053,22 @@ de:
en: 'Englisch'
de: 'Deutsch'
mailer:
+ warning: "MaMpf Warning"
+ pending_deletion_subject: "%{num_days_until_deletion} Tage bis zur
+ Löschung Deines MaMpf-Accounts"
+ deletion_subject: "Account gelöscht"
+ pending_deletion_body_html: >
+ Wir haben festgestellt, dass Du Dich in den letzten 6 Monaten nicht in
+ MaMpf eingeloggt hast. Dein Account wird in %{num_days_until_deletion}
+ Tagen gelöscht, sofern Du Dich nicht vorher einloggst.
+ Bitte beachte, dass nach der Löschung Deines Accounts alle bei MaMpf
+ gespeicherten Daten von Dir unwiderruflich verloren gehen.
+ deletion_body_html: >
+ Dein MaMpf-Account wurde vollständig gelöscht, da Du Dich seit
+ mehr als 6 Monaten nicht eingeloggt hast. Beachte, dass wir zuvor
+ mehrere Erinnerungen gesendet haben. Alle Deine bei MaMpf gespeicherten
+ Daten sind unwiderruflich verloren gegangen. Vielen Dank, dass Du MaMpf
+ genutzt hast.
notification: 'MaMpf-Benachrichtigung'
notification_header: 'Benachrichtigung von MaMpf'
medium_subject: 'Neues Medium'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6fae4ba62..454915f45 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2871,6 +2871,21 @@ en:
no_zip: This file is not a .zip archive.
to_big: This file is too big.
mailer:
+ warning: "MaMpf Warning"
+ pending_deletion_subject: "%{num_days_until_deletion} days before your
+ account will be deleted"
+ deletion_subject: "Account deleted"
+ pending_deletion_body_html: >
+ We've noticed that you haven't logged in to your MaMpf account in the last
+ 6 months. Your account will be deleted in %{num_days_until_deletion} days
+ unless you log in to your MaMpf account before then.
+ Please note that once your account is deleted, all of your data stored
+ at MaMpf will be permanently lost.
+ deletion_body_html: >
+ Your MaMpf account has been deleted entirely as you have not
+ logged in for more than 6 months. Note that we've sent several
+ reminders beforehand. All of your data stored at MaMpf has been
+ permanently lost. Thanks for using MaMpf.
notification: 'MaMpf notification'
notification_header: 'MaMpf notification'
medium_subject: 'New Medium'
diff --git a/config/schedule.yml b/config/schedule.yml
index f2018526a..5421af058 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -14,6 +14,9 @@ cache_cleaner_job:
queue: critical
user_cleaner_job:
- cron: "0 4 * * 1,4"
+ # at 4:00 AM, every day
+ # This job has to run every day such that users will receive
+ # respective warning emails (about their accounts being deleted) on time.
+ cron: "0 4 * * *"
class: "UserCleanerJob"
- queue: critical
\ No newline at end of file
+ queue: critical
diff --git a/db/migrate/20240530200000_add_deletion_date_to_user.rb b/db/migrate/20240530200000_add_deletion_date_to_user.rb
new file mode 100644
index 000000000..cf95a22d8
--- /dev/null
+++ b/db/migrate/20240530200000_add_deletion_date_to_user.rb
@@ -0,0 +1,5 @@
+class AddDeletionDateToUser < ActiveRecord::Migration[7.1]
+ def change
+ add_column :users, :deletion_date, :date, null: true, default: nil
+ end
+end
diff --git a/db/migrate/20240605200000_remove_ghost_hash_from_user.rb b/db/migrate/20240605200000_remove_ghost_hash_from_user.rb
new file mode 100644
index 000000000..609d74045
--- /dev/null
+++ b/db/migrate/20240605200000_remove_ghost_hash_from_user.rb
@@ -0,0 +1,5 @@
+class RemoveGhostHashFromUser < ActiveRecord::Migration[7.1]
+ def change
+ remove_column :users, :ghost_hash, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6cd1b3f61..9f40b301b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2024_04_22_200000) do
+ActiveRecord::Schema[7.1].define(version: 2024_06_05_200000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -875,12 +875,12 @@
t.boolean "archived"
t.datetime "locked_at", precision: nil
t.text "image_data"
- t.string "ghost_hash"
t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
+ t.date "deletion_date"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
diff --git a/docker/development/docker-compose.yml b/docker/development/docker-compose.yml
index 94e3b7591..103ffb436 100644
--- a/docker/development/docker-compose.yml
+++ b/docker/development/docker-compose.yml
@@ -85,6 +85,7 @@ services:
TEST_DATABASE_USERNAME: mampf
TEST_DATABASE_HOST: db
TEST_DATABASE_PORT: 5432
+ MAX_DELETIONS_PER_RUN: 50
MAILSERVER: mailcatcher
FROM_ADDRESS: development@localhost
URL_HOST: localhost
diff --git a/docker/production/docker-compose.production.yml b/docker/production/docker-compose.production.yml
index 8b4a8e55b..3a3ed0b36 100644
--- a/docker/production/docker-compose.production.yml
+++ b/docker/production/docker-compose.production.yml
@@ -4,6 +4,8 @@ x-mampf:
context: https://github.com/MaMpf-HD/mampf.git#main
dockerfile: docker/production/Dockerfile
env_file: docker.env
+ environment:
+ PRODUCTION_NAME:
networks:
- solr
- mampf
diff --git a/docker/production/docker.env b/docker/production/docker.env
index 886040d36..c24b29c93 100644
--- a/docker/production/docker.env
+++ b/docker/production/docker.env
@@ -11,6 +11,9 @@ MUESLI_SERVER=https://muesli.mathi.uni-heidelberg.de
ERDBEERE_API=https://erdbeere.mathi.uni-heidelberg.de/api/v1
MEMCACHED_SERVER=cache
+# User Cleaner
+MAX_DELETIONS_PER_RUN=50
+
# Email
FROM_ADDRESS=mampf@mathi.uni-heidelberg.de
MAILSERVER=mail.mathi.uni-heidelberg.de
diff --git a/docker/test/docker-compose.yml b/docker/test/docker-compose.yml
index d795b4ad3..204712069 100644
--- a/docker/test/docker-compose.yml
+++ b/docker/test/docker-compose.yml
@@ -47,6 +47,7 @@ services:
TEST_DATABASE_USERNAME: mampf
TEST_DATABASE_HOST: db
TEST_DATABASE_PORT: 5432
+ MAX_DELETIONS_PER_RUN: 50
MAILSERVER: mailcatcher
FROM_ADDRESS: development@localhost
URL_HOST: mampf
diff --git a/spec/factories/user_cleaners.rb b/spec/factories/user_cleaners.rb
deleted file mode 100644
index 8180e5f24..000000000
--- a/spec/factories/user_cleaners.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-FactoryBot.define do
- factory :user_cleaner do
- # trait with user
- trait :with_hashed_user do
- after(:build) do |c|
- user = FactoryBot.create(:confirmed_user, email: Faker::Internet.email)
- user.update(ghost_hash: Digest::SHA256.hexdigest(Time.now.to_i.to_s))
- c.hash_dict = { user.email => user.ghost_hash }
- end
- end
- end
-end
diff --git a/spec/models/teachable_parser_spec.rb b/spec/models/teachable_parser_spec.rb
index d021b5c17..9c3031464 100644
--- a/spec/models/teachable_parser_spec.rb
+++ b/spec/models/teachable_parser_spec.rb
@@ -1,3 +1,5 @@
+require "rails_helper"
+
RSpec.describe(TeachableParser, type: :model) do
it "has a valid factory" do
expect(FactoryBot.build(:teachable_parser)).to be_kind_of(TeachableParser)
diff --git a/spec/models/user_cleaner_spec.rb b/spec/models/user_cleaner_spec.rb
index d33724a05..689962e3e 100644
--- a/spec/models/user_cleaner_spec.rb
+++ b/spec/models/user_cleaner_spec.rb
@@ -1,19 +1,282 @@
require "rails_helper"
RSpec.describe(UserCleaner, type: :model) do
- it "has a factory" do
- expect(FactoryBot.build(:user_cleaner))
- .to be_kind_of(UserCleaner)
- end
- # Right now, delete_ghosts is commented out in user_cleaner.rb
- # as we want to avoid accidental deletion of users in production.
- # We will come up with a new strategy for cleaning up old users.
- #
- # it "can destroy users" do
- # n_users = User.all.size
- # u = FactoryBot.build(:user_cleaner, :with_hashed_user)
- # expect(User.all.size).to eq(n_users + 1)
- # u.delete_ghosts
- # expect(User.all.size).to eq(n_users)
- # end
+ # Non-generic users are either admins, teachers or editors
+ let(:user_admin) do
+ return FactoryBot.create(:user, deletion_date: Date.current - 1.day, admin: true)
+ end
+
+ let(:user_teacher) do
+ user_teacher = FactoryBot.create(:user, deletion_date: Date.current - 1.day)
+ FactoryBot.create(:lecture, teacher: user_teacher)
+ return user_teacher
+ end
+
+ let(:user_editor) do
+ user_editor = FactoryBot.create(:user, deletion_date: Date.current - 1.day)
+ FactoryBot.create(:lecture, editors: [user_editor])
+ return user_editor
+ end
+
+ before(:each) do
+ UserCleaner::INACTIVE_USER_THRESHOLD = 6.months
+ end
+
+ describe("#inactive_users") do
+ it "counts users without last_sign_in_at date as inactive" do
+ FactoryBot.create(:user, last_sign_in_at: nil)
+ expect(UserCleaner.new.inactive_users.count).to eq(1)
+ end
+
+ it("counts users with last_sign_in_at date older than threshold as inactive") do
+ FactoryBot.create(:user, last_sign_in_at: 7.months.ago)
+ expect(UserCleaner.new.inactive_users.count).to eq(1)
+ end
+
+ it "does not count users with last_sign_in_at date younger than threshold as inactive" do
+ FactoryBot.create(:user, last_sign_in_at: 5.months.ago)
+ expect(UserCleaner.new.inactive_users.count).to eq(0)
+ end
+ end
+
+ describe("#set/unset_deletion_date") do
+ context "when deletion date is nil" do
+ it "assigns a deletion date to inactive users" do
+ inactive_user = FactoryBot.create(:user, last_sign_in_at: 7.months.ago)
+ active_user = FactoryBot.create(:user, last_sign_in_at: 5.months.ago)
+
+ UserCleaner.new.set_deletion_date_for_inactive_users
+ inactive_user.reload
+ active_user.reload
+
+ expect(inactive_user.deletion_date).to eq(Date.current + 40.days)
+ expect(active_user.deletion_date).to be_nil
+ end
+
+ it "only assigns a deletion date to a limited number of users" do
+ max_deletions = 3
+ UserCleaner::MAX_DELETIONS_PER_RUN = max_deletions
+
+ FactoryBot.create_list(:user, max_deletions + 2, last_sign_in_at: 7.months.ago)
+
+ UserCleaner.new.set_deletion_date_for_inactive_users
+
+ users_flagged = User.where(deletion_date: Date.current + 40.days)
+ expect(users_flagged.count).to eq(max_deletions)
+ end
+ end
+
+ context "when a deletion date is assigned" do
+ it "does not overwrite the deletion date" do
+ user = FactoryBot.create(:user, last_sign_in_at: 7.months.ago,
+ deletion_date: Date.current + 42.days)
+
+ UserCleaner.new.set_deletion_date_for_inactive_users
+ user.reload
+
+ expect(user.deletion_date).to eq(Date.current + 42.days)
+ end
+ end
+
+ it "unassigns a deletion date from recently active users" do
+ deletion_date = Date.current + 5.days
+ user_inactive = FactoryBot.create(:user, deletion_date: deletion_date,
+ last_sign_in_at: 7.months.ago)
+ user_inactive2 = FactoryBot.create(:user, deletion_date: deletion_date,
+ last_sign_in_at: 6.months.ago - 1.day)
+ user_active = FactoryBot.create(:user, deletion_date: deletion_date,
+ last_sign_in_at: 2.days.ago)
+
+ UserCleaner.new.unset_deletion_date_for_recently_active_users
+ user_inactive.reload
+ user_inactive2.reload
+ user_active.reload
+
+ expect(user_inactive.deletion_date).to eq(deletion_date)
+ expect(user_inactive2.deletion_date).to eq(deletion_date)
+ expect(user_active.deletion_date).to be_nil
+ end
+ end
+
+ describe("#delete_users") do
+ it "deletes users with a deletion date in the past or present" do
+ user_past1 = FactoryBot.create(:user, deletion_date: Date.current - 1.day)
+ user_past2 = FactoryBot.create(:user, deletion_date: Date.current - 1.year)
+ user_present = FactoryBot.create(:user, deletion_date: Date.current)
+
+ UserCleaner.new.delete_users_according_to_deletion_date!
+
+ expect(User.where(id: user_past1.id)).not_to exist
+ expect(User.where(id: user_past2.id)).not_to exist
+ expect(User.where(id: user_present.id)).not_to exist
+ end
+
+ it "does not delete users with a deletion date in the future" do
+ user_future1 = FactoryBot.create(:user, deletion_date: Date.current + 1.day)
+ user_future2 = FactoryBot.create(:user, deletion_date: Date.current + 1.year)
+
+ UserCleaner.new.delete_users_according_to_deletion_date!
+
+ expect(User.where(id: user_future1.id)).to exist
+ expect(User.where(id: user_future2.id)).to exist
+ end
+
+ it "does not delete users without a deletion date" do
+ user = FactoryBot.create(:user, deletion_date: nil)
+ UserCleaner.new.delete_users_according_to_deletion_date!
+ expect(User.where(id: user.id)).to exist
+ end
+
+ it "deletes only generic users" do
+ user_generic = FactoryBot.create(:user, deletion_date: Date.current - 1.day)
+ user_admin
+ user_teacher
+ user_editor
+
+ UserCleaner.new.delete_users_according_to_deletion_date!
+
+ expect(User.where(id: user_generic.id)).not_to exist
+ expect(User.where(id: user_admin.id)).to exist
+ expect(User.where(id: user_teacher.id)).to exist
+ expect(User.where(id: user_editor.id)).to exist
+ end
+ end
+
+ describe("mails") do
+ context "when setting a deletion date" do
+ it "enqueues a deletion warning mail (40 days)" do
+ FactoryBot.create(:user, last_sign_in_at: 7.months.ago)
+
+ expect do
+ UserCleaner.new.set_deletion_date_for_inactive_users
+ end.to have_enqueued_mail(UserCleanerMailer, :pending_deletion_email)
+ .with(a_hash_including(args: [40]))
+ end
+
+ it "does not enqueue a deletion warning mail (40 days) for non-generic users" do
+ user_admin
+ user_teacher
+ user_editor
+
+ expect do
+ UserCleaner.new.set_deletion_date_for_inactive_users
+ end.not_to have_enqueued_mail(UserCleanerMailer, :pending_deletion_email)
+ end
+ end
+
+ context "when a deletion date is assigned" do
+ def test_enqueues_additional_deletion_warning_mails(num_days)
+ FactoryBot.create(:user, deletion_date: Date.current + num_days.days)
+
+ expect do
+ UserCleaner.new.send_additional_warning_mails
+ end.to have_enqueued_mail(UserCleanerMailer, :pending_deletion_email)
+ .with(a_hash_including(args: [num_days]))
+ end
+
+ it "enqueues additional deletion warning mails" do
+ test_enqueues_additional_deletion_warning_mails(14)
+ test_enqueues_additional_deletion_warning_mails(7)
+ test_enqueues_additional_deletion_warning_mails(2)
+ end
+
+ it "does not enqueue an additional deletion warning mail for 40 days" do
+ FactoryBot.create(:user, deletion_date: Date.current + 40.days)
+
+ expect do
+ UserCleaner.new.send_additional_warning_mails
+ end.not_to have_enqueued_mail(UserCleanerMailer, :pending_deletion_email)
+ end
+
+ it "does not enqueue additional deletion warning mails for non-generic users" do
+ user_admin.update(deletion_date: Date.current + 14.days)
+ user_teacher.update(deletion_date: Date.current + 7.days)
+ user_editor.update(deletion_date: Date.current + 2.days)
+
+ expect do
+ UserCleaner.new.send_additional_warning_mails
+ end.not_to have_enqueued_mail(UserCleanerMailer, :pending_deletion_email)
+ end
+ end
+
+ context "when a user is finally deleted" do
+ it "enqueues a deletion mail" do
+ FactoryBot.create(:user, deletion_date: Date.current - 1.day)
+
+ expect do
+ UserCleaner.new.delete_users_according_to_deletion_date!
+ end.to have_enqueued_mail(UserCleanerMailer, :deletion_email)
+ end
+ end
+ end
+
+ describe("#pending_deletion_mail") do
+ let(:user_de) { FactoryBot.create(:user, locale: "de") }
+ let(:user_en) { FactoryBot.create(:user, locale: "en") }
+
+ def test_subject_line(num_days)
+ user = FactoryBot.create(:user)
+ mailer = UserCleanerMailer.with(user: user).pending_deletion_email(num_days)
+ expect(mailer.subject).to include(num_days.to_s)
+ end
+
+ it "has mail subject containing the number of days until deletion" do
+ test_subject_line(40)
+ test_subject_line(14)
+ test_subject_line(7)
+ test_subject_line(2)
+ end
+
+ it "has mail subject localized to the user's locale" do
+ mailer = UserCleanerMailer.with(user: user_de).pending_deletion_email(40)
+ expect(mailer.subject).to include("Tage")
+ expect(mailer.subject).not_to include("days")
+
+ mailer = UserCleanerMailer.with(user: user_en).pending_deletion_email(40)
+ expect(mailer.subject).to include("days")
+ expect(mailer.subject).not_to include("Tage")
+ end
+
+ it "has mail body localized to the user's locale" do
+ expected_de = "verloren"
+ expected_en = "lost"
+
+ mailer = UserCleanerMailer.with(user: user_de).pending_deletion_email(40)
+ expect(mailer.html_part.body).to include(expected_de)
+ expect(mailer.html_part.body).not_to include(expected_en)
+
+ mailer = UserCleanerMailer.with(user: user_en).pending_deletion_email(40)
+ expect(mailer.html_part.body).to include(expected_en)
+ expect(mailer.html_part.body).not_to include(expected_de)
+ end
+ end
+
+ describe("#deletion_mail") do
+ let(:user_de) { FactoryBot.create(:user, locale: "de") }
+ let(:user_en) { FactoryBot.create(:user, locale: "en") }
+
+ it "has mail subject localized to the user's locale" do
+ mailer = UserCleanerMailer.with(user: user_de).deletion_email
+ expect(mailer.subject).to include("gelöscht")
+ expect(mailer.subject).not_to include("deleted")
+
+ mailer = UserCleanerMailer.with(user: user_en).deletion_email
+ expect(mailer.subject).to include("deleted")
+ expect(mailer.subject).not_to include("gelöscht")
+ end
+
+ it "has mail body localized to the user's locale" do
+ expected_de = "vollständig gelöscht"
+ expected_en = "deleted entirely"
+
+ mailer = UserCleanerMailer.with(user: user_de).deletion_email
+
+ expect(mailer.html_part.body).to include(expected_de)
+ expect(mailer.html_part.body).not_to include(expected_en)
+
+ mailer = UserCleanerMailer.with(user: user_en).deletion_email
+ expect(mailer.html_part.body).to include(expected_en)
+ expect(mailer.html_part.body).not_to include(expected_de)
+ end
+ end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 202207bd1..57c66cd8d 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -35,6 +35,10 @@
# For Devise >= 4.1.0
config.include Devise::Test::ControllerHelpers, type: :controller
+ # e.g. make have_enqueued_mail matchers available
+ # see https://stackoverflow.com/a/57077395/
+ config.include ActiveJob::TestHelper
+
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.