discourse/spec/lib/discourse_spec.rb
Martin Brennan 628873de24
FIX: Sort plugins by their setting category name (#25128)
Some plugins have names (e.g. discourse-x-yz) that
are totally different from what they are actually called,
and that causes issues when showing them in a sorted way
in the admin plugin list.

Now, we should use the setting category name from client.en.yml
if it exists, otherwise fall back to the name, for sorting.
This is what we do on the client to determine what text to
show for the plugin name as well.
2024-01-08 09:57:25 +10:00

686 lines
21 KiB
Ruby

# frozen_string_literal: true
require "discourse"
RSpec.describe Discourse do
before { RailsMultisite::ConnectionManagement.stubs(:current_hostname).returns("foo.com") }
describe "current_hostname" do
it "returns the hostname from the current db connection" do
expect(Discourse.current_hostname).to eq("foo.com")
end
end
describe "avatar_sizes" do
it "returns a list of integers" do
SiteSetting.avatar_sizes = "10|20|30"
expect(Discourse.avatar_sizes).to contain_exactly(10, 20, 30)
end
end
describe "running_in_rack" do
after { ENV.delete("DISCOURSE_RUNNING_IN_RACK") }
it "should not be running in rack" do
expect(Discourse.running_in_rack?).to eq(false)
ENV["DISCOURSE_RUNNING_IN_RACK"] = "1"
expect(Discourse.running_in_rack?).to eq(true)
end
end
describe "base_url" do
context "when https is off" do
before { SiteSetting.force_https = false }
it "has a non https base url" do
expect(Discourse.base_url).to eq("http://foo.com")
end
end
context "when https is on" do
before { SiteSetting.force_https = true }
it "has a non-ssl base url" do
expect(Discourse.base_url).to eq("https://foo.com")
end
end
context "with a non standard port specified" do
before { SiteSetting.port = 3000 }
it "returns the non standart port in the base url" do
expect(Discourse.base_url).to eq("http://foo.com:3000")
end
end
end
describe "asset_filter_options" do
it "obmits path if request is missing" do
opts = Discourse.asset_filter_options(:js, nil)
expect(opts[:path]).to be_blank
end
it "returns a hash with a path from the request" do
req = stub(fullpath: "/hello", headers: {})
opts = Discourse.asset_filter_options(:js, req)
expect(opts[:path]).to eq("/hello")
end
end
describe ".plugins_sorted_by_name" do
before do
Discourse.stubs(:visible_plugins).returns(
[
stub(enabled?: false, name: "discourse-doctor-sleep", humanized_name: "Doctor Sleep"),
stub(enabled?: true, name: "discourse-shining", humanized_name: "The Shining"),
stub(enabled?: true, name: "discourse-misery", humanized_name: "misery"),
],
)
end
it "sorts enabled plugins by humanized name" do
expect(Discourse.plugins_sorted_by_name.map(&:name)).to eq(
%w[discourse-misery discourse-shining],
)
end
it "sorts both enabled and disabled plugins when that option is provided" do
expect(Discourse.plugins_sorted_by_name(enabled_only: false).map(&:name)).to eq(
%w[discourse-doctor-sleep discourse-misery discourse-shining],
)
end
end
describe "plugins" do
let(:plugin_class) do
Class.new(Plugin::Instance) do
attr_accessor :enabled
def enabled?
@enabled
end
end
end
let(:plugin1) do
plugin_class.new.tap do |p|
p.enabled = true
p.path = "my-plugin-1"
end
end
let(:plugin2) do
plugin_class.new.tap do |p|
p.enabled = false
p.path = "my-plugin-1"
end
end
before { Discourse.plugins.append(plugin1, plugin2) }
after do
Discourse.plugins.delete plugin1
Discourse.plugins.delete plugin2
DiscoursePluginRegistry.reset!
end
before do
plugin_class.any_instance.stubs(:css_asset_exists?).returns(true)
plugin_class.any_instance.stubs(:js_asset_exists?).returns(true)
end
it "can find plugins correctly" do
expect(Discourse.plugins).to include(plugin1, plugin2)
# Exclude disabled plugins by default
expect(Discourse.find_plugins({})).to include(plugin1)
# Include disabled plugins when requested
expect(Discourse.find_plugins(include_disabled: true)).to include(plugin1, plugin2)
end
it "can find plugin assets" do
plugin2.enabled = true
expect(Discourse.find_plugin_css_assets({}).length).to eq(2)
expect(Discourse.find_plugin_js_assets({}).length).to eq(2)
plugin1.register_asset_filter { |type, request, opts| false }
expect(Discourse.find_plugin_css_assets({}).length).to eq(1)
expect(Discourse.find_plugin_js_assets({}).length).to eq(1)
end
end
describe "authenticators" do
it "returns inbuilt authenticators" do
expect(Discourse.authenticators).to match_array(Discourse::BUILTIN_AUTH.map(&:authenticator))
end
context "with authentication plugin installed" do
let(:plugin_auth_provider) do
authenticator_class =
Class.new(Auth::Authenticator) do
def name
"pluginauth"
end
def enabled?
true
end
end
provider = Auth::AuthProvider.new
provider.authenticator = authenticator_class.new
provider
end
before { DiscoursePluginRegistry.register_auth_provider(plugin_auth_provider) }
after { DiscoursePluginRegistry.reset! }
it "returns inbuilt and plugin authenticators" do
expect(Discourse.authenticators).to match_array(
Discourse::BUILTIN_AUTH.map(&:authenticator) + [plugin_auth_provider.authenticator],
)
end
end
end
describe "enabled_authenticators" do
it "only returns enabled authenticators" do
expect(Discourse.enabled_authenticators.length).to be(0)
expect { SiteSetting.enable_twitter_logins = true }.to change {
Discourse.enabled_authenticators.length
}.by(1)
expect(Discourse.enabled_authenticators.length).to be(1)
expect(Discourse.enabled_authenticators.first).to be_instance_of(Auth::TwitterAuthenticator)
end
end
describe "#site_contact_user" do
fab!(:admin)
fab!(:another_admin) { Fabricate(:admin) }
it "returns the user specified by the site setting site_contact_username" do
SiteSetting.site_contact_username = another_admin.username
expect(Discourse.site_contact_user).to eq(another_admin)
end
it "returns the system user otherwise" do
SiteSetting.site_contact_username = nil
expect(Discourse.site_contact_user.username).to eq("system")
end
end
describe "#system_user" do
it "returns the system user" do
expect(Discourse.system_user.id).to eq(-1)
end
end
describe "#store" do
it "returns LocalStore by default" do
expect(Discourse.store).to be_a(FileStore::LocalStore)
end
it "returns S3Store when S3 is enabled" do
SiteSetting.enable_s3_uploads = true
SiteSetting.s3_upload_bucket = "s3bucket"
SiteSetting.s3_access_key_id = "s3_access_key_id"
SiteSetting.s3_secret_access_key = "s3_secret_access_key"
expect(Discourse.store).to be_a(FileStore::S3Store)
end
end
describe "readonly mode" do
let(:readonly_mode_key) { Discourse::READONLY_MODE_KEY }
let(:readonly_mode_ttl) { Discourse::READONLY_MODE_KEY_TTL }
let(:user_readonly_mode_key) { Discourse::USER_READONLY_MODE_KEY }
after do
Discourse.redis.del(readonly_mode_key)
Discourse.redis.del(user_readonly_mode_key)
end
def assert_readonly_mode(message, key, ttl = -1)
expect(message.channel).to eq(Discourse.readonly_channel)
expect(message.data).to eq(true)
expect(Discourse.redis.get(key)).to eq("1")
expect(Discourse.redis.ttl(key)).to eq(ttl)
end
def assert_readonly_mode_disabled(message, key)
expect(message.channel).to eq(Discourse.readonly_channel)
expect(message.data).to eq(false)
expect(Discourse.redis.get(key)).to eq(nil)
end
describe ".enable_readonly_mode" do
it "adds a key in redis and publish a message through the message bus" do
expect(Discourse.redis.get(readonly_mode_key)).to eq(nil)
end
context "when user enabled readonly mode" do
it "adds a key in redis and publish a message through the message bus" do
expect(Discourse.redis.get(user_readonly_mode_key)).to eq(nil)
end
end
end
describe ".disable_readonly_mode" do
context "when user disabled readonly mode" do
it "removes readonly key in redis and publish a message through the message bus" do
message =
MessageBus
.track_publish { Discourse.disable_readonly_mode(user_readonly_mode_key) }
.first
assert_readonly_mode_disabled(message, user_readonly_mode_key)
end
end
end
describe ".readonly_mode?" do
it "is false by default" do
expect(Discourse.readonly_mode?).to eq(false)
end
it "returns true when the key is present in redis" do
Discourse.redis.set(readonly_mode_key, 1)
expect(Discourse.readonly_mode?).to eq(true)
end
it "returns true when postgres is recently read only" do
Discourse.received_postgres_readonly!
expect(Discourse.readonly_mode?).to eq(true)
end
it "returns true when redis is recently read only" do
Discourse.received_redis_readonly!
expect(Discourse.readonly_mode?).to eq(true)
end
it "returns true when user enabled readonly mode key is present in redis" do
Discourse.enable_readonly_mode(user_readonly_mode_key)
expect(Discourse.readonly_mode?).to eq(true)
expect(Discourse.readonly_mode?(readonly_mode_key)).to eq(false)
Discourse.disable_readonly_mode(user_readonly_mode_key)
expect(Discourse.readonly_mode?).to eq(false)
end
it "returns true when forced via global setting" do
expect(Discourse.readonly_mode?).to eq(false)
global_setting :pg_force_readonly_mode, true
expect(Discourse.readonly_mode?).to eq(true)
end
end
describe ".received_postgres_readonly!" do
it "sets the right time" do
time = Discourse.received_postgres_readonly!
expect(Discourse.redis.get(Discourse::LAST_POSTGRES_READONLY_KEY).to_i).to eq(time.to_i)
end
end
describe ".received_redis_readonly!" do
it "sets the right time" do
time = Discourse.received_redis_readonly!
expect(Discourse.redis_last_read_only["default"]).to eq(time)
end
end
describe ".clear_readonly!" do
it "publishes the right message" do
Discourse.received_postgres_readonly!
messages = []
expect do messages = MessageBus.track_publish { Discourse.clear_readonly! } end.to change {
Discourse.redis.get(Discourse::LAST_POSTGRES_READONLY_KEY)
}.to(nil)
expect(messages.any? { |m| m.channel == Site::SITE_JSON_CHANNEL }).to eq(true)
end
end
end
describe "#handle_exception" do
class TempSidekiqLogger
attr_accessor :exception, :context
def call(ex, ctx)
self.exception = ex
self.context = ctx
end
end
let!(:logger) { TempSidekiqLogger.new }
before { Sidekiq.error_handlers << logger }
after { Sidekiq.error_handlers.delete(logger) }
describe "#job_exception_stats" do
class FakeTestError < StandardError
end
before { Discourse.reset_job_exception_stats! }
after { Discourse.reset_job_exception_stats! }
it "should not fail on incorrectly shaped hash" do
expect do
Discourse.handle_job_exception(FakeTestError.new, { job: "test" })
end.to raise_error(FakeTestError)
end
it "should collect job exception stats" do
# see MiniScheduler Manager which reports it like this
# https://github.com/discourse/mini_scheduler/blob/2b2c1c56b6e76f51108c2a305775469e24cf2b65/lib/mini_scheduler/manager.rb#L95
exception_context = {
message: "Running a scheduled job",
job: {
"class" => Jobs::ReindexSearch,
},
}
# re-raised unconditionally in test env
2.times do
expect {
Discourse.handle_job_exception(FakeTestError.new, exception_context)
}.to raise_error(FakeTestError)
end
exception_context = {
message: "Running a scheduled job",
job: {
"class" => Jobs::PollMailbox,
},
}
expect {
Discourse.handle_job_exception(FakeTestError.new, exception_context)
}.to raise_error(FakeTestError)
expect(Discourse.job_exception_stats).to eq(
{ Jobs::PollMailbox => 1, Jobs::ReindexSearch => 2 },
)
end
end
it "should not fail when called" do
exception = StandardError.new
expect do Discourse.handle_job_exception(exception, nil, nil) end.to raise_error(
StandardError,
) # Raises in test mode, catch it
expect(logger.exception).to eq(exception)
expect(logger.context.keys).to eq(%i[current_db current_hostname])
end
it "correctly passes extra context" do
exception = StandardError.new
expect do
Discourse.handle_job_exception(exception, { message: "Doing a test", post_id: 31 }, nil)
end.to raise_error(StandardError) # Raises in test mode, catch it
expect(logger.exception).to eq(exception)
expect(logger.context.keys.sort).to eq(%i[current_db current_hostname message post_id].sort)
end
end
describe "#deprecate" do
def old_method(m)
Discourse.deprecate(m)
end
def old_method_caller(m)
old_method(m)
end
before do
@orig_logger = Rails.logger
Rails.logger = @fake_logger = FakeLogger.new
end
after { Rails.logger = @orig_logger }
it "can deprecate usage" do
k = SecureRandom.hex
expect(old_method_caller(k)).to include("old_method_caller")
expect(old_method_caller(k)).to include("discourse_spec")
expect(old_method_caller(k)).to include(k)
expect(@fake_logger.warnings).to eq([old_method_caller(k)])
end
it "can report the deprecated version" do
Discourse.deprecate(SecureRandom.hex, since: "2.1.0.beta1")
expect(@fake_logger.warnings[0]).to include("(deprecated since Discourse 2.1.0.beta1)")
end
it "can report the drop version" do
Discourse.deprecate(SecureRandom.hex, drop_from: "2.3.0")
expect(@fake_logger.warnings[0]).to include("(removal in Discourse 2.3.0)")
end
it "can raise deprecation error" do
expect { Discourse.deprecate(SecureRandom.hex, raise_error: true) }.to raise_error(
Discourse::Deprecation,
)
end
end
describe "Utils.execute_command" do
it "works for individual commands" do
expect(Discourse::Utils.execute_command("pwd").strip).to eq(Rails.root.to_s)
expect(Discourse::Utils.execute_command("pwd", chdir: "plugins").strip).to eq(
"#{Rails.root}/plugins",
)
end
it "supports timeouts" do
expect do
Discourse::Utils.execute_command("sleep", "999999999999", timeout: 0.001)
end.to raise_error(RuntimeError)
expect do
Discourse::Utils.execute_command(
{ "MYENV" => "MYVAL" },
"sleep",
"999999999999",
timeout: 0.001,
)
end.to raise_error(RuntimeError)
end
it "works with a block" do
Discourse::Utils.execute_command do |runner|
expect(runner.exec("pwd").strip).to eq(Rails.root.to_s)
end
result =
Discourse::Utils.execute_command(chdir: "plugins") do |runner|
expect(runner.exec("pwd").strip).to eq("#{Rails.root}/plugins")
runner.exec("pwd")
end
# Should return output of block
expect(result.strip).to eq("#{Rails.root}/plugins")
end
it "does not leak chdir between threads" do
has_done_chdir = false
has_checked_chdir = false
thread =
Thread.new do
Discourse::Utils.execute_command(chdir: "plugins") do
has_done_chdir = true
sleep(0.01) until has_checked_chdir
end
end
sleep(0.01) until has_done_chdir
expect(Discourse::Utils.execute_command("pwd").strip).to eq(Rails.root.to_s)
has_checked_chdir = true
thread.join
end
it "raises error for unsafe shell" do
expect(Discourse::Utils.execute_command("pwd").strip).to eq(Rails.root.to_s)
expect do Discourse::Utils.execute_command("echo a b c") end.to raise_error(RuntimeError)
expect do
Discourse::Utils.execute_command({ "ENV1" => "VAL" }, "echo a b c")
end.to raise_error(RuntimeError)
expect(Discourse::Utils.execute_command("echo", "a", "b", "c").strip).to eq("a b c")
expect(Discourse::Utils.execute_command("echo a b c", unsafe_shell: true).strip).to eq(
"a b c",
)
end
end
describe ".clear_all_theme_cache!" do
before do
setup_s3
SiteSetting.s3_cdn_url = "https://s3.cdn.com/gg"
stub_s3_store
end
let!(:theme) { Fabricate(:theme) }
let!(:upload) { Fabricate(:s3_image_upload) }
let!(:upload_theme_field) do
Fabricate(
:theme_field,
theme: theme,
upload: upload,
type_id: ThemeField.types[:theme_upload_var],
target_id: Theme.targets[:common],
name: "imajee",
value: "",
)
end
let!(:basic_html_field) do
Fabricate(
:theme_field,
theme: theme,
type_id: ThemeField.types[:html],
target_id: Theme.targets[:common],
name: "head_tag",
value: <<~HTML,
<script type="text/discourse-plugin" version="0.1">
console.log(settings.uploads.imajee);
</script>
HTML
)
end
let!(:js_field) do
Fabricate(
:theme_field,
theme: theme,
type_id: ThemeField.types[:js],
target_id: Theme.targets[:extra_js],
name: "somefile.js",
value: <<~JS,
console.log(settings.uploads.imajee);
JS
)
end
let!(:scss_field) do
Fabricate(
:theme_field,
theme: theme,
type_id: ThemeField.types[:scss],
target_id: Theme.targets[:common],
name: "scss",
value: <<~SCSS,
.something { background: url($imajee); }
SCSS
)
end
it "invalidates all JS and CSS caches" do
Stylesheet::Manager.clear_theme_cache!
old_upload_url = Discourse.store.cdn_url(upload.url)
head_tag_script =
Nokogiri::HTML5
.fragment(Theme.lookup_field(theme.id, :desktop, "head_tag"))
.css("script")
.first
head_tag_js = JavascriptCache.find_by(digest: head_tag_script[:src][/\h{40}/]).content
expect(head_tag_js).to include(old_upload_url)
js_file_script =
Nokogiri::HTML5.fragment(Theme.lookup_field(theme.id, :extra_js, nil)).css("script").first
file_js = JavascriptCache.find_by(digest: js_file_script[:src][/\h{40}/]).content
expect(file_js).to include(old_upload_url)
css_link_tag =
Nokogiri::HTML5
.fragment(
Stylesheet::Manager.new(theme_id: theme.id).stylesheet_link_tag(:desktop_theme, "all"),
)
.css("link")
.first
css = StylesheetCache.find_by(digest: css_link_tag[:href][/\h{40}/]).content
expect(css).to include("url(#{old_upload_url})")
SiteSetting.s3_cdn_url = "https://new.s3.cdn.com/gg"
new_upload_url = Discourse.store.cdn_url(upload.url)
head_tag_script =
Nokogiri::HTML5
.fragment(Theme.lookup_field(theme.id, :desktop, "head_tag"))
.css("script")
.first
head_tag_js = JavascriptCache.find_by(digest: head_tag_script[:src][/\h{40}/]).content
expect(head_tag_js).to include(old_upload_url)
js_file_script =
Nokogiri::HTML5.fragment(Theme.lookup_field(theme.id, :extra_js, nil)).css("script").first
file_js = JavascriptCache.find_by(digest: js_file_script[:src][/\h{40}/]).content
expect(file_js).to include(old_upload_url)
css_link_tag =
Nokogiri::HTML5
.fragment(
Stylesheet::Manager.new(theme_id: theme.id).stylesheet_link_tag(:desktop_theme, "all"),
)
.css("link")
.first
css = StylesheetCache.find_by(digest: css_link_tag[:href][/\h{40}/]).content
expect(css).to include("url(#{old_upload_url})")
Discourse.clear_all_theme_cache!
head_tag_script =
Nokogiri::HTML5
.fragment(Theme.lookup_field(theme.id, :desktop, "head_tag"))
.css("script")
.first
head_tag_js = JavascriptCache.find_by(digest: head_tag_script[:src][/\h{40}/]).content
expect(head_tag_js).to include(new_upload_url)
js_file_script =
Nokogiri::HTML5.fragment(Theme.lookup_field(theme.id, :extra_js, nil)).css("script").first
file_js = JavascriptCache.find_by(digest: js_file_script[:src][/\h{40}/]).content
expect(file_js).to include(new_upload_url)
css_link_tag =
Nokogiri::HTML5
.fragment(
Stylesheet::Manager.new(theme_id: theme.id).stylesheet_link_tag(:desktop_theme, "all"),
)
.css("link")
.first
css = StylesheetCache.find_by(digest: css_link_tag[:href][/\h{40}/]).content
expect(css).to include("url(#{new_upload_url})")
end
end
end