2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-08-17 18:04:11 +08:00

FIX: improve "read only" modes (#33521)

The reasons for these changes is https://meta.discourse.org/t/-/89605
broke and admins were not able to log back in if they had previously
enabled the "read only" mode.

Thus ensued a deep dive into how all the "read only" modes worked, which
was made difficult due to the lack of tests.

The "cornerstone" of this PR is the `read_only_mixin.rb` file which was
improved to be able to differentiate between the "readonly" mode and the
"staff writes only" mode.

I then made use of the `allow_in_readonly_mode` and
`allow_in_staff_writes_only_mode` method to **explicitely** list all the
actions that should work in those modes.

I also added the "readonly" mixin to the `WebhooksController` since it
doesn't inherit from the `ApplicationController`.

I improved the security of the `/u/admin-login` endpoint by always
sending the same message no matter if we found or not an admin account
with the provided email address.

I added two system specs:

1. for ensuring that admins can log in via /u/admin-lgoin and then
clicking the link in the email they received while the site is in
readonly mode.
2. for ensuring the "staff writes only mode" is _actually_ tested by
ensuring a moderator can log in and create a topic while the site is in
that mode.

Plenty of specs were updated to ensure 100% converage of the various
"read only" modes.
This commit is contained in:
Régis Hanol 2025-07-10 09:08:00 +02:00 committed by GitHub
parent 0dcbbe0de4
commit bccf4e0b53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 521 additions and 126 deletions

View file

@ -8,7 +8,20 @@ class Admin::BackupsController < Admin::AdminController
before_action :ensure_backups_enabled
skip_before_action :check_xhr, only: %i[index show logs check_backup_chunk upload_backup_chunk]
skip_before_action :ensure_backups_enabled, only: %w[show status index email]
skip_before_action :ensure_backups_enabled, only: %i[show status index email]
allow_in_readonly_mode :create,
:cancel,
:email,
:destroy,
:restore,
:rollback,
:readonly,
:upload_backup_chunk,
:create_multipart,
:complete_multipart,
:abort_multipart,
:batch_presign_multipart_parts
def index
respond_to do |format|

View file

@ -145,7 +145,7 @@ class ApplicationController < ActionController::Base
rescue_from PG::ReadOnlySqlTransaction do |e|
Discourse.received_postgres_readonly!
Rails.logger.error("#{e.class} #{e.message}: #{e.backtrace.join("\n")}")
rescue_with_handler(Discourse::ReadOnly.new) || raise
rescue_with_handler(Discourse::ReadOnly) || raise
end
rescue_from ActionController::ParameterMissing do |e|
@ -580,9 +580,7 @@ class ApplicationController < ActionController::Base
def rate_limit_second_factor!(user)
return if params[:second_factor_token].blank?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 6, 1.minute).performed!
RateLimiter.new(nil, "second-factor-min-#{user.username}", 6, 1.minute).performed! if user
end

View file

@ -29,6 +29,9 @@ class CategoriesController < ApplicationController
]
skip_before_action :verify_authenticity_token, only: %i[search]
# The front-end is POSTing data to this endpoint, but we're not modifying anything
allow_in_readonly_mode :search
SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR = 1.5
MIN_CATEGORIES_TOPICS = 5
MAX_CATEGORIES_LIMIT = 25

View file

@ -42,8 +42,6 @@ class PresenceController < ApplicationController
end
def update
raise Discourse::ReadOnly if @readonly_mode
client_id = params[:client_id]
if !client_id.is_a?(String) || client_id.blank?
raise Discourse::InvalidParameters.new(:client_id)

View file

@ -12,7 +12,8 @@ class SessionController < ApplicationController
skip_before_action :check_xhr, only: %i[second_factor_auth_show]
allow_in_staff_writes_only_mode :create, :email_login, :forgot_password
allow_in_readonly_mode :email_login
allow_in_staff_writes_only_mode :create, :forgot_password
ACTIVATE_USER_KEY = "activate_user"
FORGOT_PASSWORD_EMAIL_LIMIT_PER_DAY = 6
@ -107,7 +108,6 @@ class SessionController < ApplicationController
def become
raise Discourse::InvalidAccess if Rails.env.production?
raise Discourse::ReadOnly if @readonly_mode
if ENV["DISCOURSE_DEV_ALLOW_ANON_TO_IMPERSONATE"] != "1"
return render plain: <<~TEXT, status: 403
@ -166,7 +166,7 @@ class SessionController < ApplicationController
def sso_login
raise Discourse::NotFound unless SiteSetting.enable_discourse_connect
raise Discourse::ReadOnly if @readonly_mode && !staff_writes_only_mode?
raise Discourse::ReadOnly if @readonly_mode && !@staff_writes_only_mode
params.require(:sso)
params.require(:sig)
@ -207,7 +207,7 @@ class SessionController < ApplicationController
invite = validate_invitiation!(sso)
if user = sso.lookup_or_create_user(request.remote_ip)
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
raise Discourse::ReadOnly if @staff_writes_only_mode && !user&.staff?
if user.suspended?
render_sso_error(text: failed_to_login(user)[:error], status: 403)
@ -332,7 +332,7 @@ class SessionController < ApplicationController
user = User.find_by_username_or_email(normalized_login_param)
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
raise Discourse::ReadOnly if @staff_writes_only_mode && !user&.staff?
rate_limit_second_factor!(user)
@ -446,11 +446,9 @@ class SessionController < ApplicationController
def email_login
token = params[:token]
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
user = matched_token&.user
user = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])&.user
check_local_login_allowed(user: user, check_login_via_email: true)
rate_limit_second_factor!(user)
if user.present? && !authenticate_second_factor(user).ok
@ -458,12 +456,17 @@ class SessionController < ApplicationController
end
if user = EmailToken.confirm(token, scope: EmailToken.scopes[:email_login])
if @staff_writes_only_mode
raise Discourse::ReadOnly unless user.staff?
elsif @readonly_mode
raise Discourse::ReadOnly unless user.admin?
end
if login_not_approved_for?(user)
return render json: login_not_approved
elsif payload = login_error_check(user)
return render json: payload
else
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
user.update_timezone_if_missing(params[:timezone])
log_on_user(user)
return render json: success_json
@ -636,7 +639,7 @@ class SessionController < ApplicationController
end
if user
raise Discourse::ReadOnly if staff_writes_only_mode? && !user.staff?
raise Discourse::ReadOnly if @staff_writes_only_mode && !user.staff?
enqueue_password_reset_for_user(user)
else
RateLimiter.new(

View file

@ -1,4 +1,3 @@
# -*- encoding : utf-8 -*-
# frozen_string_literal: true
class Users::OmniauthCallbacksController < ApplicationController
@ -13,6 +12,7 @@ class Users::OmniauthCallbacksController < ApplicationController
# will not have a CSRF token, however the payload is all validated so its safe
skip_before_action :verify_authenticity_token, only: :complete
# These are usually GET requests but some providers use POST requests
allow_in_staff_writes_only_mode :complete
def confirm_request
@ -21,17 +21,15 @@ class Users::OmniauthCallbacksController < ApplicationController
end
def complete
auth = request.env["omniauth.auth"]
raise Discourse::NotFound unless request.env["omniauth.auth"]
raise Discourse::ReadOnly if @readonly_mode && !staff_writes_only_mode?
raise Discourse::ReadOnly if @readonly_mode && !@staff_writes_only_mode
raise Discourse::NotFound unless auth = request.env["omniauth.auth"]
auth[:session] = session
authenticator = self.class.find_authenticator(params[:provider])
if session.delete(:auth_reconnect) && authenticator.can_connect_existing_user? && current_user
path = persist_auth_token(auth)
return redirect_to path
return redirect_to persist_auth_token(auth)
else
DiscourseEvent.trigger(:before_auth, authenticator, auth, session, cookies, request)
@auth_result = authenticator.after_authenticate(auth)
@ -71,7 +69,7 @@ class Users::OmniauthCallbacksController < ApplicationController
return render_auth_result_failure if @auth_result.failed?
raise Discourse::ReadOnly if staff_writes_only_mode? && !@auth_result.user&.staff?
raise Discourse::ReadOnly if @staff_writes_only_mode && !@auth_result.user&.staff?
complete_response_data

View file

@ -107,7 +107,8 @@ class UsersController < ApplicationController
before_action :add_noindex_header, only: %i[show my_redirect]
allow_in_staff_writes_only_mode :admin_login, :email_login, :password_reset_update
allow_in_readonly_mode :admin_login
allow_in_staff_writes_only_mode :email_login, :password_reset_update
MAX_RECENT_SEARCHES = 5
@ -862,7 +863,6 @@ class UsersController < ApplicationController
RateLimiter.new(nil, "remove-password-hr-#{user.username}", 6, 1.hour).performed!
raise Discourse::NotFound if !user || !user.user_password
raise Discourse::ReadOnly if staff_writes_only_mode? && !user.staff?
raise Discourse::InvalidAccess if !session_is_trusted?
user.remove_password
@ -872,16 +872,15 @@ class UsersController < ApplicationController
def password_reset_update
expires_now
token = params[:token]
password_reset_find_user(token, committing_change: true)
token = params[:token]
password_reset_find_user(token, committing_change: true)
rate_limit_second_factor!(@user)
# no point doing anything else if we can't even find
# a user from the token
if @user
raise Discourse::ReadOnly if staff_writes_only_mode? && !@user.staff?
raise Discourse::ReadOnly if @staff_writes_only_mode && !@user&.staff?
if @user
if !secure_session["second-factor-#{token}"]
second_factor_authentication_result =
@user.authenticate_second_factor(params, secure_session)
@ -1008,21 +1007,17 @@ class UsersController < ApplicationController
RateLimiter.new(nil, "admin-login-hr-#{request.remote_ip}", 6, 1.hour).performed!
RateLimiter.new(nil, "admin-login-min-#{request.remote_ip}", 3, 1.minute).performed!
if user = User.with_email(params[:email]).admins.human_users.first
if user = User.real.admins.with_email(params[:email]).first
email_token =
user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:email_login])
token_string = email_token.token
token_string += "?safe_mode=no_plugins,no_themes" if params["use_safe_mode"]
Jobs.enqueue(
:critical_user_email,
type: "admin_login",
user_id: user.id,
email_token: token_string,
)
@message = I18n.t("admin_login.success")
else
@message = I18n.t("admin_login.errors.unknown_email_address")
user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:email_login]).token
email_token += "?safe_mode=no_plugins,no_themes" if params["use_safe_mode"]
Jobs.enqueue(:critical_user_email, type: "admin_login", user_id: user.id, email_token:)
end
# NOTE: we don't check for `readonly` mode here because it might leak information about whether
# an email address is a registered admin account.
@message = I18n.t("admin_login.acknowledgement", email: params[:email])
end
render layout: "no_ember"
@ -1040,13 +1035,15 @@ class UsersController < ApplicationController
RateLimiter.new(nil, "email-login-hour-#{request.remote_ip}", 6, 1.hour).performed!
RateLimiter.new(nil, "email-login-min-#{request.remote_ip}", 3, 1.minute).performed!
user = User.human_users.find_by_username_or_email(params[:login])
user = User.real.find_by_username_or_email(params[:login])
user_presence = user.present? && !user.staged
if user
RateLimiter.new(nil, "email-login-hour-#{user.id}", 6, 1.hour).performed!
RateLimiter.new(nil, "email-login-min-#{user.id}", 3, 1.minute).performed!
raise Discourse::ReadOnly if @staff_writes_only_mode && !user.staff?
if user_presence
DiscourseEvent.trigger(:before_email_login, user)

View file

@ -1,10 +1,17 @@
# frozen_string_literal: true
require "openssl"
class WebhooksController < ActionController::Base
include ReadOnlyMixin
skip_before_action :verify_authenticity_token
before_action :check_readonly_mode
before_action :block_if_readonly_mode
rescue_from Discourse::ReadOnly do
head :service_unavailable
end
def mailgun
return signature_failure if SiteSetting.mailgun_api_key.blank?
@ -279,7 +286,7 @@ class WebhooksController < ActionController::Base
begin
public_key = OpenSSL::PKey::EC.new(Base64.decode64(SiteSetting.sendgrid_verification_key))
rescue StandardError => err
rescue StandardError
Rails.logger.error("Invalid Sendgrid verification key")
return false
end

View file

@ -5307,10 +5307,7 @@ en:
badge_title_metadata: "%{display_name} badge on %{site_title}"
admin_login:
success: "Email Sent"
errors:
unknown_email_address: "Unknown email address."
invalid_token: "Invalid token."
acknowledgement: "If an admin account with the email address %{email} exists, you will receive an email with a link to log in."
email_input: "Admin Email"
submit_button: "Send Email"
safe_mode: "Safe Mode: disable all themes/plugins when logging in"

View file

@ -109,13 +109,14 @@ class ApplicationLayoutPreloader
end
def preload_anonymous_data
check_readonly_mode if @readonly_mode.nil?
@preloaded["site"] = Site.json_for(@guardian)
@preloaded["siteSettings"] = SiteSetting.client_settings_json
@preloaded["customHTML"] = custom_html_json
@preloaded["banner"] = banner_json
@preloaded["customEmoji"] = custom_emoji
@preloaded["isReadOnly"] = get_or_check_readonly_mode.to_json
@preloaded["isStaffWritesOnly"] = get_or_check_staff_writes_only_mode.to_json
@preloaded["isReadOnly"] = @readonly_mode.to_json
@preloaded["isStaffWritesOnly"] = @staff_writes_only_mode.to_json
@preloaded["activatedThemes"] = activated_themes_json
end

View file

@ -2,21 +2,29 @@
module ReadOnlyMixin
module ClassMethods
def actions_allowed_in_readonly_mode
@actions_allowed_in_readonly_mode ||= []
end
def actions_allowed_in_staff_writes_only_mode
@actions_allowed_in_staff_writes_only_mode ||= []
end
def allow_in_readonly_mode(*actions)
actions_allowed_in_readonly_mode.concat(actions.map(&:to_sym))
end
def allow_in_staff_writes_only_mode(*actions)
actions_allowed_in_staff_writes_only_mode.concat(actions.map(&:to_sym))
end
def allowed_in_staff_writes_only_mode?(action_name)
actions_allowed_in_staff_writes_only_mode.include?(action_name.to_sym)
def allowed_in_readonly_mode?(action)
actions_allowed_in_readonly_mode.include?(action.to_sym)
end
end
def staff_writes_only_mode?
@staff_writes_only_mode
def allowed_in_staff_writes_only_mode?(action)
actions_allowed_in_staff_writes_only_mode.include?(action.to_sym)
end
end
def check_readonly_mode
@ -32,34 +40,25 @@ module ReadOnlyMixin
end
end
def get_or_check_readonly_mode
check_readonly_mode if @readonly_mode.nil?
@readonly_mode
end
def get_or_check_staff_writes_only_mode
check_readonly_mode if @staff_writes_only_mode.nil?
@staff_writes_only_mode
end
def add_readonly_header
response.headers["Discourse-Readonly"] = "true" if @readonly_mode
end
def allowed_in_readonly_mode?
self.class.allowed_in_readonly_mode?(action_name)
end
def allowed_in_staff_writes_only_mode?
self.class.allowed_in_staff_writes_only_mode?(action_name)
end
def block_if_readonly_mode
return if request.fullpath.start_with?(path "/admin/backups")
return if request.fullpath.start_with?(path "/categories/search")
return if request.get? || request.head?
if @staff_writes_only_mode
raise Discourse::ReadOnly.new if !current_user&.staff? && !allowed_in_staff_writes_only_mode?
elsif @readonly_mode
raise Discourse::ReadOnly.new
if @staff_writes_only_mode && (allowed_in_staff_writes_only_mode? || current_user&.staff?)
return
end
return if !@readonly_mode || allowed_in_readonly_mode?
raise Discourse::ReadOnly
end
def self.included(base)

View file

@ -143,7 +143,7 @@ RSpec.describe Middleware::RequestTracker do
# /srv/status is never a tracked view because content-type is text/plain
data =
Middleware::RequestTracker.get_data(
env("HTTP_USER_AGENT" => "kube-probe/1.18", "REQUEST_URI" => "/srv/status?shutdown_ok=1"),
env("HTTP_USER_AGENT" => "kube-probe/1.18", "REQUEST_URI" => "/srv/status"),
["200", { "Content-Type" => "text/plain" }],
0.1,
)

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
describe ReadOnlyMixin do
before { Rails.application.eager_load! }
it "allows only these actions in readonly mode" do
controllers_with_readonly_actions = []
ApplicationController.descendants.each do |controller_class|
controller_class.actions_allowed_in_readonly_mode&.each do |action|
controllers_with_readonly_actions << [controller_class, action]
end
end
expect(controllers_with_readonly_actions).to contain_exactly(
# All the /admin/backups actions that modify data
[Admin::BackupsController, :readonly],
[Admin::BackupsController, :create],
[Admin::BackupsController, :cancel],
[Admin::BackupsController, :restore],
[Admin::BackupsController, :rollback],
[Admin::BackupsController, :destroy],
[Admin::BackupsController, :email],
[Admin::BackupsController, :upload_backup_chunk],
[Admin::BackupsController, :create_multipart],
[Admin::BackupsController, :abort_multipart],
[Admin::BackupsController, :complete_multipart],
[Admin::BackupsController, :batch_presign_multipart_parts],
# Search uses a POST request but doesn't modify any data
[CategoriesController, :search],
# Allows admins to log in (via email) when the site is in readonly mode (cf. https://meta.discourse.org/t/-/89605)
[SessionController, :email_login],
[UsersController, :admin_login],
)
end
it "allows only these actions in staff writes only mode" do
controllers_with_staff_writes_only_actions = []
ApplicationController.descendants.each do |controller_class|
controller_class.actions_allowed_in_staff_writes_only_mode&.each do |action|
controllers_with_staff_writes_only_actions << [controller_class, action]
end
end
expect(controllers_with_staff_writes_only_actions).to contain_exactly(
# Allows staff to log in using email/username and password
[SessionController, :create],
# Allows staff to reset their password (part 1/2)
[SessionController, :forgot_password],
# Allows staff to log in via OAuth
[Users::OmniauthCallbacksController, :complete],
# Allows staff to log in via email link
[UsersController, :email_login],
# Allows staff to reset their password (part 2/2)
[UsersController, :password_reset_update],
)
end
end

View file

@ -155,6 +155,16 @@ RSpec.describe Admin::BackupsController do
expect(response.status).to eq(200)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "starts a backup" do
BackupRestore.expects(:backup!)
post "/admin/backups.json", params: { with_uploads: false, client_id: "foo" }
expect(response.status).to eq(200)
end
end
context "with rate limiting enabled" do
before { RateLimiter.enable }
@ -284,6 +294,18 @@ RSpec.describe Admin::BackupsController do
delete "/admin/backups/#{backup_filename}.json"
expect(response.status).to eq(404)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "removes the backup if found" do
create_backup_files(backup_filename)
expect { delete "/admin/backups/#{backup_filename}.json" }.to change {
UserHistory.where(action: UserHistory.actions[:backup_destroy]).count
}.by(1)
end
end
end
shared_examples "backup deletion not allowed" do
@ -367,6 +389,16 @@ RSpec.describe Admin::BackupsController do
expect(response.status).to eq(200)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "starts a restore" do
BackupRestore.expects(:restore!)
post "/admin/backups/#{backup_filename}/restore.json", params: { client_id: "foo" }
expect(response.status).to eq(200)
end
end
end
shared_examples "backup restoration not allowed" do
@ -527,6 +559,29 @@ RSpec.describe Admin::BackupsController do
expect(response.status).to eq(200)
expect(response.body).to eq("")
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "uploads the file successfully" do
described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true)
post "/admin/backups/upload.json",
params: {
resumableFilename: "foo-1234.tar.gz",
resumableTotalSize: 1,
resumableIdentifier: "test",
resumableChunkNumber: "1",
resumableChunkSize: "1",
resumableCurrentChunkSize: "1",
file: fixture_file_upload(Tempfile.new),
}
expect_job_enqueued(job: :backup_chunks_merger)
expect(response.status).to eq(200)
end
end
end
describe "completing an upload by enqueuing backup_chunks_merger" do
@ -748,6 +803,16 @@ RSpec.describe Admin::BackupsController do
get "/admin/backups/rollback.json"
expect(response.status).to eq(404)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "should rollback the restore" do
BackupRestore.expects(:rollback!)
post "/admin/backups/rollback.json"
expect(response.status).to eq(200)
end
end
end
shared_examples "backup rollback not allowed" do
@ -788,6 +853,18 @@ RSpec.describe Admin::BackupsController do
get "/admin/backups/cancel.json"
expect(response.status).to eq(404)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "should cancel an backup" do
BackupRestore.expects(:cancel!)
delete "/admin/backups/cancel.json"
expect(response.status).to eq(200)
end
end
end
shared_examples "backup cancellation not allowed" do
@ -845,6 +922,18 @@ RSpec.describe Admin::BackupsController do
expect(response.status).to eq(200)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "enqueues email job" do
create_backup_files(backup_filename)
expect { put "/admin/backups/#{backup_filename}.json" }.to change {
Jobs::DownloadBackupEmail.jobs.size
}.by(1)
end
end
it "returns 404 when the backup does not exist" do
put "/admin/backups/#{backup_filename}.json"
@ -973,6 +1062,23 @@ RSpec.describe Admin::BackupsController do
expect(external_upload_stub.exists?).to eq(true)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "creates the multipart upload" do
stub_create_multipart_backup_request
post "/admin/backups/create-multipart.json",
params: {
file_name: "test.tar.gz",
upload_type: upload_type,
file_size: 4098,
}
expect(response.status).to eq(200)
end
end
context "when backup of same filename already exists" do
let(:backup_file_exists_response) { { status: 200, body: "" } }

View file

@ -101,7 +101,7 @@ RSpec.describe CategoriesController do
end
it "does not return subcategories without query param" do
subcategory = Fabricate(:category, user: admin, parent_category: category)
Fabricate(:category, user: admin, parent_category: category)
sign_in(user)
@ -343,8 +343,8 @@ RSpec.describe CategoriesController do
category1 = Fabricate(:category)
category2 = Fabricate(:category)
upload = Fabricate(:upload)
topic1 = Fabricate(:topic, category: category1)
topic2 = Fabricate(:topic, category: category1, image_upload: upload)
Fabricate(:topic, category: category1)
Fabricate(:topic, category: category1, image_upload: upload)
CategoryFeaturedTopic.feature_topics
SiteSetting.desktop_category_page_style = "categories_with_featured_topics"
@ -363,8 +363,7 @@ RSpec.describe CategoriesController do
response.parsed_body["category_list"]["categories"].find { |c| c["id"] == category1.id }
expect(category_response["topics"].count).to eq(2)
upload = Fabricate(:upload)
topic3 = Fabricate(:topic, category: category2, image_upload: upload)
Fabricate(:topic, category: category2, image_upload: Fabricate(:upload))
CategoryFeaturedTopic.feature_topics
second_request_queries =
@ -689,14 +688,13 @@ RSpec.describe CategoriesController do
it "returns errors when there is a name conflict while moving a category into another" do
parent_category = Fabricate(:category, name: "Parent", user: admin)
other_category =
Fabricate(
:category,
name: category.name,
user: admin,
parent_category: parent_category,
slug: "a-different-slug",
)
Fabricate(
:category,
name: category.name,
user: admin,
parent_category:,
slug: "a-different-slug",
)
put "/categories/#{category.id}.json", params: { parent_category_id: parent_category.id }
@ -704,7 +702,7 @@ RSpec.describe CategoriesController do
end
it "returns 422 if email_in address is already in use for other category" do
_other_category = Fabricate(:category, name: "Other", email_in: "mail@example.com")
Fabricate(:category, name: "Other", email_in: "mail@example.com")
put "/categories/#{category.id}.json",
params: {
@ -1569,6 +1567,22 @@ RSpec.describe CategoriesController do
)
end
end
context "when in readonly mode" do
before { Discourse.enable_readonly_mode }
it "works" do
post "/categories/search.json", params: { term: "" }
expect(response.status).to eq(200)
expect(response.parsed_body["categories"].map { |c| c["id"] }).to contain_exactly(
SiteSetting.uncategorized_category_id,
category.id,
subcategory.id,
category2.id,
)
end
end
end
describe "#hierachical_search" do

View file

@ -16,7 +16,7 @@ RSpec.describe ForumsController do
end
it "returns a readonly header if the site is in staff-writes-only mode" do
Discourse.stubs(:staff_writes_only_mode?).returns(true)
Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY)
get "/srv/status"
expect(response.status).to eq(200)
expect(response.headers["Discourse-Readonly"]).to eq("true")
@ -43,10 +43,5 @@ RSpec.describe ForumsController do
expect(response.status).to eq(200)
expect(response.body).not_to include("not match")
end
it "returns a 200 response when given shutdown_ok" do
get "/srv/status?shutdown_ok=1"
expect(response.status).to eq(200)
end
end
end

View file

@ -224,34 +224,50 @@ RSpec.describe Users::OmniauthCallbacksController do
end
context "when in readonly mode" do
it "should return a 503" do
Discourse.enable_readonly_mode
before { Discourse.enable_readonly_mode }
it "returns a 503 (GET)" do
get "/auth/google_oauth2/callback"
expect(response.code).to eq("503")
expect(response.status).to eq(503)
end
it "returns a 503 (POST)" do
post "/auth/google_oauth2/callback"
expect(response.status).to eq(503)
end
end
context "when in staff writes only mode" do
before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }
it "returns a 503 for non-staff" do
it "returns a 503 for non-staff (GET)" do
mock_auth(user.email, user.username, user.name)
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(503)
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
expect(logged_on_user).to eq(nil)
expect(Discourse.current_user_provider.new(request.env).current_user).to eq(nil)
end
it "completes for staff" do
it "returns a 503 for non-staff (POST)" do
mock_auth(user.email, user.username, user.name)
post "/auth/google_oauth2/callback.json"
expect(response.status).to eq(503)
expect(Discourse.current_user_provider.new(request.env).current_user).to eq(nil)
end
it "completes for admins (GET)" do
user.update!(admin: true)
mock_auth(user.email, user.username, user.name)
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
expect(Discourse.current_user_provider.new(request.env).current_user).to eq(user)
end
expect(logged_on_user).not_to eq(nil)
it "completes for moderators (POST)" do
user.update!(moderator: true)
mock_auth(user.email, user.username, user.name)
post "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
expect(Discourse.current_user_provider.new(request.env).current_user).to eq(user)
end
end
@ -635,7 +651,6 @@ RSpec.describe Users::OmniauthCallbacksController do
provider_uid: "123545",
)
old_email = user.email
user.update!(email: "email@example.com")
get "/auth/google_oauth2/callback.json"
@ -911,8 +926,6 @@ RSpec.describe Users::OmniauthCallbacksController do
SiteSetting.google_oauth2_hd_groups = true
stub_request(:post, "https://oauth2.googleapis.com/token").to_return do |request|
jwt = Rack::Utils.parse_query(request.body)["assertion"]
decoded_token = JWT.decode(jwt, private_key.public_key, true, { algorithm: "RS256" })
{
status: 200,
body: { "access_token" => token, "type" => "bearer" }.to_json,

View file

@ -131,12 +131,34 @@ RSpec.describe SessionController do
end
describe "#email_login" do
let(:email_token) do
Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login])
end
let(:email_token) { Fabricate(:email_token, user:, scope: EmailToken.scopes[:email_login]) }
before { SiteSetting.enable_local_logins_via_email = true }
context "when in readonly mode" do
before { Discourse.enable_readonly_mode }
it "allows admins to login" do
user.update!(admin: true)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(user.id)
end
it "does not allow moderators to login" do
user.update!(moderator: true)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(503)
expect(session[:current_user_id]).to eq(nil)
end
it "does not allow regular users to login" do
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(503)
expect(session[:current_user_id]).to eq(nil)
end
end
context "when in staff writes only mode" do
before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }
@ -147,7 +169,14 @@ RSpec.describe SessionController do
expect(session[:current_user_id]).to eq(user.id)
end
it "does not allow other users to login" do
it "allows moderators to login" do
user.update!(moderator: true)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(user.id)
end
it "does not allow regular users to login" do
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(503)
expect(session[:current_user_id]).to eq(nil)

View file

@ -208,7 +208,7 @@ RSpec.describe UsersController do
end
end
describe "#remove password" do
describe "#remove_password" do
it "responds forbidden when not logged in" do
put "/u/#{user.username}/remove-password.json"
expect(response.status).to eq(403)
@ -750,7 +750,7 @@ RSpec.describe UsersController do
end
describe "#admin_login" do
it "enqueues mail with admin email and sso enabled" do
it "enqueues mail with admin email" do
put "/u/admin-login", params: { email: admin.email }
expect(response.status).to eq(200)
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
@ -767,15 +767,26 @@ RSpec.describe UsersController do
end
context "when email is incorrect" do
it "should return the right response" do
put "/u/admin-login", params: { email: "random" }
it "doesn't enqueue the mail and returns the same message" do
expect { put "/u/admin-login", params: { email: "random" } }.to_not change {
Jobs::CriticalUserEmail.jobs.size
}
expect(response.status).to eq(200)
expect(response.body).to match(I18n.t("admin_login.acknowledgement", email: "random"))
end
end
response_body = response.body
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
expect(response_body).to match(I18n.t("admin_login.errors.unknown_email_address"))
expect(response_body).to_not match(I18n.t("login.second_factor_description"))
it "enqueues mail with admin email" do
expect { put "/u/admin-login", params: { email: admin.email } }.to change {
Jobs::CriticalUserEmail.jobs.size
}.by(1)
expect(response.status).to eq(200)
expect(response.body).to match(I18n.t("admin_login.acknowledgement", email: admin.email))
end
end
end
@ -4725,8 +4736,7 @@ RSpec.describe UsersController do
RateLimiter.enable
freeze_time
user = post_user
token = user.email_tokens.first
post_user
6.times do |n|
put "/u/update-activation-email.json",
@ -4826,8 +4836,7 @@ RSpec.describe UsersController do
RateLimiter.enable
freeze_time
user = inactive_user
token = user.email_tokens.first
inactive_user
6.times do |n|
put "/u/update-activation-email.json",
@ -5777,6 +5786,40 @@ RSpec.describe UsersController do
)
end
describe "when staff writes only mode is enabled" do
before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }
it "enqueues the right email for moderator" do
user1.update!(moderator: true)
expect { post "/u/email-login.json", params: { login: user1.email } }.to change {
Jobs::CriticalUserEmail.jobs.count
}.by(1)
expect(response.status).to eq(200)
expect(response.parsed_body["user_found"]).to eq(true)
end
it "enqueues the right email for admin" do
user1.update!(admin: true)
expect { post "/u/email-login.json", params: { login: user1.email } }.to change {
Jobs::CriticalUserEmail.jobs.count
}.by(1)
expect(response.status).to eq(200)
expect(response.parsed_body["user_found"]).to eq(true)
end
it "does not enqueue the email for a regular user" do
expect { post "/u/email-login.json", params: { login: user1.email } }.not_to change {
Jobs::CriticalUserEmail.jobs.count
}
expect(response.status).to eq(503)
end
end
describe "when enable_local_logins_via_email is disabled" do
before { SiteSetting.enable_local_logins_via_email = false }
@ -6430,7 +6473,6 @@ RSpec.describe UsersController do
it "renames the key" do
sign_in(user1)
put "/u/rename_passkey/#{passkey.id}.json", params: { name: "new name" }
response_parsed = response.parsed_body
expect(response.status).to eq(200)
expect(passkey.reload.name).to eq("new name")

View file

@ -79,6 +79,31 @@ RSpec.describe WebhooksController do
expect(email_log.bounce_error_code).to eq("5.1.1")
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score)
end
context "when readonly mode is enabled" do
before { Discourse.enable_readonly_mode }
it "returns 503" do
user = Fabricate(:user, email:)
email_log = Fabricate(:email_log, user:, message_id:, to_address: email)
post "/webhooks/mailgun.json",
params: {
"token" => token,
"timestamp" => timestamp,
"event" => "dropped",
"recipient" => email,
"Message-Id" => "<#{message_id}>",
"signature" => signature,
"error" =>
"smtp; 550-5.1.1 The email account that you tried to reach does not exist.",
"code" => "5.1.1",
}
expect(response.status).to eq(503)
expect(email_log.reload.bounced).to eq(false)
end
end
end
describe "#sendgrid" do

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
describe "Admin email login in readonly mode", type: :system do
fab!(:admin)
context "when site is in readonly mode" do
before { Discourse.enable_readonly_mode }
it "allows admin to request email login from /u/admin-login page" do
Jobs.run_immediately!
ActionMailer::Base.deliveries.clear
page.visit "/u/admin-login"
fill_in "email", with: admin.email
click_button "Send Email"
expect(page).to have_content(I18n.t("admin_login.acknowledgement", email: admin.email))
expect(ActionMailer::Base.deliveries.count).to eq(1)
mail = ActionMailer::Base.deliveries.last
expect(mail.to).to contain_exactly(admin.email)
expect(mail.body.to_s).to include("/session/email-login/")
end
it "allows admin to login via email token during readonly mode" do
email_token =
admin.email_tokens.create!(email: admin.email, scope: EmailToken.scopes[:email_login])
page.visit "/session/email-login/#{email_token.token}"
find(".email-login-form .btn-primary").click
expect(page).to have_css(".header-dropdown-toggle.current-user")
expect(page).to have_content(admin.username)
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
describe "Staff writes only mode", type: :system do
password = SecureRandom.alphanumeric(20)
fab!(:moderator) { Fabricate(:moderator, password:) }
fab!(:user) { Fabricate(:user, password:) }
fab!(:topic) { Fabricate(:topic, user:) }
fab!(:post) { Fabricate(:post, topic:, user:) }
let(:login_form) { PageObjects::Pages::Login.new }
let(:composer) { PageObjects::Components::Composer.new }
before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }
context "when moderator" do
before { EmailToken.confirm(Fabricate(:email_token, user: moderator).token) }
it "can login and post during staff writes only mode" do
login_form.open.fill(username: moderator.username, password:).click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
page.visit "/new-topic"
expect(composer).to be_opened
title = "Test topic from moderator"
body = "This is a test post created by a moderator during staff writes only mode."
composer.fill_title(title)
composer.fill_content(body)
composer.create
expect(page).to have_content(title)
expect(page).to have_content(body)
end
end
context "when regular user" do
before { EmailToken.confirm(Fabricate(:email_token, user:).token) }
it "cannot login during staff writes only mode" do
login_form.open.fill(username: user.username, password:).click_login
expect(page).not_to have_css(".header-dropdown-toggle.current-user")
expect(page).to have_css("input#login-account-name")
end
it "can view topics but sees staff only mode message when not logged in" do
page.visit topic.url
expect(page).to have_content(topic.title)
expect(page).to have_content(post.raw)
expect(page).to have_content(I18n.t("js.staff_writes_only_mode.enabled"))
end
end
end