discourse/spec/requests/application_controller_spec.rb

824 lines
26 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ApplicationController do
describe '#redirect_to_login_if_required' do
let(:admin) { Fabricate(:admin) }
before do
admin # to skip welcome wizard at home page `/`
SiteSetting.login_required = true
end
it "should never cache a login redirect" do
get "/"
expect(response.headers["Cache-Control"]).to eq("no-cache, no-store")
end
it "should redirect to login normally" do
get "/"
expect(response).to redirect_to("/login")
end
it "should redirect to SSO if enabled" do
SiteSetting.sso_url = 'http://someurl.com'
SiteSetting.enable_sso = true
get "/"
expect(response).to redirect_to("/session/sso")
end
it "should redirect to authenticator if only one, and local logins disabled" do
# Local logins and google enabled, direct to login UI
SiteSetting.enable_google_oauth2_logins = true
get "/"
expect(response).to redirect_to("/login")
# Only google enabled, login immediately
SiteSetting.enable_local_logins = false
get "/"
expect(response).to redirect_to("/auth/google_oauth2")
# Google and GitHub enabled, direct to login UI
SiteSetting.enable_github_logins = true
get "/"
expect(response).to redirect_to("/login")
end
it "should not redirect to SSO when external_auth_immediately is disabled" do
SiteSetting.external_auth_immediately = false
SiteSetting.sso_url = 'http://someurl.com'
SiteSetting.enable_sso = true
get "/"
expect(response).to redirect_to("/login")
end
it "should not redirect to authenticator when external_auth_immediately is disabled" do
SiteSetting.external_auth_immediately = false
SiteSetting.enable_google_oauth2_logins = true
SiteSetting.enable_local_logins = false
get "/"
expect(response).to redirect_to("/login")
end
context "with omniauth in test mode" do
before do
OmniAuth.config.test_mode = true
OmniAuth.config.add_mock(:google_oauth2,
info: OmniAuth::AuthHash::InfoHash.new(
email: "address@example.com",
),
extra: {
raw_info: OmniAuth::AuthHash.new(
email_verified: true,
email: "address@example.com",
)
}
)
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2]
end
after do
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil
OmniAuth.config.test_mode = false
end
it "should not redirect to authenticator if registration in progress" do
SiteSetting.enable_local_logins = false
SiteSetting.enable_google_oauth2_logins = true
get "/"
expect(response).to redirect_to("/auth/google_oauth2")
expect(cookies[:authentication_data]).to eq(nil)
get "/auth/google_oauth2/callback.json"
expect(response).to redirect_to("/")
expect(cookies[:authentication_data]).not_to eq(nil)
get "/"
expect(response).to redirect_to("/login")
end
end
it 'contains authentication data when cookies exist' do
COOKIE_DATA = "someauthenticationdata"
cookies['authentication_data'] = COOKIE_DATA
get '/login'
expect(response.status).to eq(200)
expect(response.body).to include("data-authentication-data=\"#{COOKIE_DATA }\"")
end
end
describe '#redirect_to_second_factor_if_required' do
let(:admin) { Fabricate(:admin) }
fab!(:user) { Fabricate(:user) }
before do
admin # to skip welcome wizard at home page `/`
end
it "should redirect admins when enforce_second_factor is 'all'" do
SiteSetting.enforce_second_factor = "all"
sign_in(admin)
get "/"
expect(response).to redirect_to("/u/#{admin.username}/preferences/second-factor")
end
it "should redirect users when enforce_second_factor is 'all'" do
SiteSetting.enforce_second_factor = "all"
sign_in(user)
get "/"
expect(response).to redirect_to("/u/#{user.username}/preferences/second-factor")
end
it "should not redirect anonymous users when enforce_second_factor is 'all'" do
SiteSetting.enforce_second_factor = "all"
SiteSetting.allow_anonymous_posting = true
sign_in(user)
post "/u/toggle-anon.json"
expect(response.status).to eq(200)
get "/"
expect(response.status).to eq(200)
end
it "should redirect admins when enforce_second_factor is 'staff'" do
SiteSetting.enforce_second_factor = "staff"
sign_in(admin)
get "/"
expect(response).to redirect_to("/u/#{admin.username}/preferences/second-factor")
end
it "should not redirect users when enforce_second_factor is 'staff'" do
SiteSetting.enforce_second_factor = "staff"
sign_in(user)
get "/"
expect(response.status).to eq(200)
end
it "should not redirect admins when turned off" do
SiteSetting.enforce_second_factor = "no"
sign_in(admin)
get "/"
expect(response.status).to eq(200)
end
it "should not redirect users when turned off" do
SiteSetting.enforce_second_factor = "no"
sign_in(user)
get "/"
expect(response.status).to eq(200)
end
it "correctly redirects for Unicode usernames" do
SiteSetting.enforce_second_factor = "all"
SiteSetting.unicode_usernames = true
user = sign_in(Fabricate(:unicode_user))
get "/"
expect(response).to redirect_to("/u/#{user.encoded_username}/preferences/second-factor")
end
context "when enforcing second factor for staff" do
before do
SiteSetting.enforce_second_factor = "staff"
sign_in(admin)
end
context "when the staff member has not enabled TOTP or security keys" do
it "redirects the staff to the second factor preferences" do
get "/"
expect(response).to redirect_to("/u/#{admin.username}/preferences/second-factor")
end
end
context "when the staff member has enabled TOTP" do
before do
Fabricate(:user_second_factor_totp, user: admin)
end
it "does not redirects the staff to set up 2FA" do
get "/"
expect(response.status).to eq(200)
end
end
context "when the staff member has enabled security keys" do
before do
Fabricate(:user_security_key_with_random_credential, user: admin)
end
it "does not redirects the staff to set up 2FA" do
get "/"
expect(response.status).to eq(200)
end
end
end
end
describe 'invalid request params' do
before do
@old_logger = Rails.logger
@logs = StringIO.new
Rails.logger = Logger.new(@logs)
end
after do
Rails.logger = @old_logger
end
it 'should not raise a 500 (nor should it log a warning) for bad params' do
bad_str = (+"d\xDE").force_encoding('utf-8')
expect(bad_str.valid_encoding?).to eq(false)
get "/latest.json", params: { test: bad_str }
expect(response.status).to eq(400)
log = @logs.string
if (log.include? 'exception app middleware')
# heisentest diagnostics
puts
puts "EXTRA DIAGNOSTICS FOR INTERMITENT TEST FAIL"
puts log
puts ">> action_dispatch.exception"
ex = request.env['action_dispatch.exception']
puts ">> exception class: #{ex.class} : #{ex}"
end
expect(log).not_to include('exception app middleware')
expect(response.parsed_body).to eq(
"status" => 400,
"error" => "Bad Request"
)
end
end
describe 'missing required param' do
it 'should return a 400' do
get "/search/query.json", params: { trem: "misspelled term" }
expect(response.status).to eq(400)
expect(response.parsed_body).to eq(
"errors" => ["param is missing or the value is empty: term"]
)
end
end
describe 'build_not_found_page' do
describe 'topic not found' do
it 'should not redirect to permalink if topic/category does not exist' do
topic = create_post.topic
Permalink.create!(url: topic.relative_url, topic_id: topic.id + 1)
topic.trash!
SiteSetting.detailed_404 = false
get topic.relative_url
expect(response.status).to eq(404)
SiteSetting.detailed_404 = true
get topic.relative_url
expect(response.status).to eq(410)
end
it 'should return permalink for deleted topics' do
topic = create_post.topic
external_url = 'https://somewhere.over.rainbow'
Permalink.create!(url: topic.relative_url, external_url: external_url)
topic.trash!
get topic.relative_url
expect(response.status).to eq(301)
expect(response).to redirect_to(external_url)
get "/t/#{topic.id}.json"
expect(response.status).to eq(301)
expect(response).to redirect_to(external_url)
get "/t/#{topic.id}.json", xhr: true
expect(response.status).to eq(200)
expect(response.body).to eq(external_url)
end
it 'supports subfolder with permalinks' do
set_subfolder "/forum"
trashed_topic = create_post.topic
trashed_topic.trash!
new_topic = create_post.topic
permalink = Permalink.create!(url: trashed_topic.relative_url, topic_id: new_topic.id)
# no subfolder because router doesn't know about subfolder in this test
get "/t/#{trashed_topic.slug}/#{trashed_topic.id}"
expect(response.status).to eq(301)
expect(response).to redirect_to("/forum/t/#{new_topic.slug}/#{new_topic.id}")
permalink.destroy
category = Fabricate(:category)
permalink = Permalink.create!(url: trashed_topic.relative_url, category_id: category.id)
get "/t/#{trashed_topic.slug}/#{trashed_topic.id}"
expect(response.status).to eq(301)
expect(response).to redirect_to("/forum/c/#{category.slug}/#{category.id}")
permalink.destroy
permalink = Permalink.create!(url: trashed_topic.relative_url, post_id: new_topic.posts.last.id)
get "/t/#{trashed_topic.slug}/#{trashed_topic.id}"
expect(response.status).to eq(301)
expect(response).to redirect_to("/forum/t/#{new_topic.slug}/#{new_topic.id}/#{new_topic.posts.last.post_number}")
end
it 'should return 404 and show Google search for an invalid topic route' do
get "/t/nope-nope/99999999"
expect(response.status).to eq(404)
response_body = response.body
expect(response_body).to include(I18n.t('page_not_found.search_button'))
expect(response_body).to have_tag("input", with: { value: 'nope nope' })
end
it 'should not include Google search if login_required is enabled' do
SiteSetting.login_required = true
sign_in(Fabricate(:user))
get "/t/nope-nope/99999999"
expect(response.status).to eq(404)
expect(response.body).to_not include('google.com/search')
end
describe 'no logspam' do
before do
@orig_logger = Rails.logger
Rails.logger = @fake_logger = FakeLogger.new
end
after do
Rails.logger = @orig_logger
end
it 'should handle 404 to a css file' do
Discourse.cache.delete("page_not_found_topics:#{I18n.locale}")
topic1 = Fabricate(:topic)
get '/stylesheets/mobile_1_4cd559272273fe6d3c7db620c617d596a5fdf240.css', headers: { 'HTTP_ACCEPT' => 'text/css,*/*,q=0.1' }
expect(response.status).to eq(404)
expect(response.body).to include(topic1.title)
topic2 = Fabricate(:topic)
get '/stylesheets/mobile_1_4cd559272273fe6d3c7db620c617d596a5fdf240.css', headers: { 'HTTP_ACCEPT' => 'text/css,*/*,q=0.1' }
expect(response.status).to eq(404)
expect(response.body).to include(topic1.title)
expect(response.body).to_not include(topic2.title)
expect(Rails.logger.fatals.length).to eq(0)
expect(Rails.logger.errors.length).to eq(0)
expect(Rails.logger.warnings.length).to eq(0)
end
end
it 'should cache results' do
Discourse.cache.delete("page_not_found_topics:#{I18n.locale}")
Discourse.cache.delete("page_not_found_topics:fr")
topic1 = Fabricate(:topic)
get '/t/nope-nope/99999999'
expect(response.status).to eq(404)
expect(response.body).to include(topic1.title)
topic2 = Fabricate(:topic)
get '/t/nope-nope/99999999'
expect(response.status).to eq(404)
expect(response.body).to include(topic1.title)
expect(response.body).to_not include(topic2.title)
# Different locale should have different cache
SiteSetting.default_locale = :fr
get '/t/nope-nope/99999999'
expect(response.status).to eq(404)
expect(response.body).to include(topic1.title)
expect(response.body).to include(topic2.title)
end
end
end
describe "#handle_theme" do
let!(:theme) { Fabricate(:theme, user_selectable: true) }
let!(:theme2) { Fabricate(:theme, user_selectable: true) }
let!(:non_selectable_theme) { Fabricate(:theme, user_selectable: false) }
fab!(:user) { Fabricate(:user) }
fab!(:admin) { Fabricate(:admin) }
before do
sign_in(user)
end
it "selects the theme the user has selected" do
user.user_option.update_columns(theme_ids: [theme.id])
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme.id])
theme.update_attribute(:user_selectable, false)
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([SiteSetting.default_theme_id])
end
it "can be overridden with a cookie" do
user.user_option.update_columns(theme_ids: [theme.id])
cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq}"
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id])
theme2.update!(user_selectable: false, component: true)
theme.add_relative_theme!(:child, theme2)
cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{user.user_option.theme_key_seq}"
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme.id, theme2.id])
end
it "falls back to the default theme when the user has no cookies or preferences" do
user.user_option.update_columns(theme_ids: [])
cookies["theme_ids"] = nil
theme2.set_default!
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id])
end
it "can be overridden with preview_theme_id param" do
sign_in(admin)
cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{admin.user_option.theme_key_seq}"
get "/", params: { preview_theme_id: theme2.id }
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id])
get "/", params: { preview_theme_id: non_selectable_theme.id }
expect(controller.theme_ids).to eq([non_selectable_theme.id])
end
it "does not allow non privileged user to preview themes" do
sign_in(user)
get "/", params: { preview_theme_id: non_selectable_theme.id }
expect(controller.theme_ids).to eq([SiteSetting.default_theme_id])
end
it "cookie can fail back to user if out of sync" do
user.user_option.update_columns(theme_ids: [theme.id])
cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq - 1}"
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme.id])
end
end
describe 'Custom hostname' do
it 'does not allow arbitrary host injection' do
get("/latest",
headers: {
"X-Forwarded-Host" => "test123.com"
}
)
expect(response.body).not_to include("test123")
end
end
describe 'allow_embedding_site_in_an_iframe' do
it "should have the 'X-Frame-Options' header with value 'sameorigin'" do
get("/latest")
expect(response.headers['X-Frame-Options']).to eq("SAMEORIGIN")
end
it "should not include the 'X-Frame-Options' header" do
SiteSetting.allow_embedding_site_in_an_iframe = true
get("/latest")
expect(response.headers).not_to include('X-Frame-Options')
end
end
describe 'Delegated auth' do
let :public_key do
<<~TXT
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDh7BS7Ey8hfbNhlNAW/47pqT7w
IhBz3UyBYzin8JurEQ2pY9jWWlY8CH147KyIZf1fpcsi7ZNxGHeDhVsbtUKZxnFV
p16Op3CHLJnnJKKBMNdXMy0yDfCAHZtqxeBOTcCo1Vt/bHpIgiK5kmaekyXIaD0n
w0z/BYpOgZ8QwnI5ZwIDAQAB
-----END PUBLIC KEY-----
TXT
end
let :args do
{
auth_redirect: 'http://no-good.com',
user_api_public_key: "not-a-valid-public-key"
}
end
it 'disallows invalid public_key param' do
args[:auth_redirect] = "discourse://auth_redirect"
get "/latest", params: args
expect(response.body).to eq(I18n.t("user_api_key.invalid_public_key"))
end
it 'does not allow invalid auth_redirect' do
args[:user_api_public_key] = public_key
get "/latest", params: args
expect(response.body).to eq(I18n.t("user_api_key.invalid_auth_redirect"))
end
it 'does not redirect if one_time_password scope is disallowed' do
SiteSetting.allow_user_api_key_scopes = "read|write"
args[:user_api_public_key] = public_key
args[:auth_redirect] = "discourse://auth_redirect"
get "/latest", params: args
expect(response.status).to_not eq(302)
expect(response).to_not redirect_to("#{args[:auth_redirect]}?otp=true")
end
it 'redirects correctly with valid params' do
SiteSetting.login_required = true
args[:user_api_public_key] = public_key
args[:auth_redirect] = "discourse://auth_redirect"
get "/categories", params: args
expect(response.status).to eq(302)
expect(response).to redirect_to("#{args[:auth_redirect]}?otp=true")
end
end
describe 'Content Security Policy' do
it 'is enabled by SiteSettings' do
SiteSetting.content_security_policy = false
SiteSetting.content_security_policy_report_only = false
get '/'
expect(response.headers).to_not include('Content-Security-Policy')
expect(response.headers).to_not include('Content-Security-Policy-Report-Only')
SiteSetting.content_security_policy = true
SiteSetting.content_security_policy_report_only = true
get '/'
expect(response.headers).to include('Content-Security-Policy')
expect(response.headers).to include('Content-Security-Policy-Report-Only')
end
it 'can be customized with SiteSetting' do
SiteSetting.content_security_policy = true
get '/'
script_src = parse(response.headers['Content-Security-Policy'])['script-src']
expect(script_src).to_not include('example.com')
SiteSetting.content_security_policy_script_src = 'example.com'
get '/'
script_src = parse(response.headers['Content-Security-Policy'])['script-src']
expect(script_src).to include('example.com')
end
it 'does not set CSP when responding to non-HTML' do
SiteSetting.content_security_policy = true
SiteSetting.content_security_policy_report_only = true
get '/latest.json'
expect(response.headers).to_not include('Content-Security-Policy')
expect(response.headers).to_not include('Content-Security-Policy-Report-Only')
end
def parse(csp_string)
csp_string.split(';').map do |policy|
directive, *sources = policy.split
[directive, sources]
end.to_h
end
end
it 'can respond to a request with */* accept header' do
get '/', headers: { HTTP_ACCEPT: '*/*' }
expect(response.status).to eq(200)
expect(response.body).to include('Discourse')
end
it 'has canonical tag' do
get '/', headers: { HTTP_ACCEPT: '*/*' }
expect(response.body).to have_tag("link", with: { rel: "canonical", href: "http://test.localhost/" })
get '/?query_param=true', headers: { HTTP_ACCEPT: '*/*' }
expect(response.body).to have_tag("link", with: { rel: "canonical", href: "http://test.localhost/" })
get '/latest?page=2&additional_param=true', headers: { HTTP_ACCEPT: '*/*' }
expect(response.body).to have_tag("link", with: { rel: "canonical", href: "http://test.localhost/latest?page=2" })
get '/404', headers: { HTTP_ACCEPT: '*/*' }
expect(response.body).to have_tag("link", with: { rel: "canonical", href: "http://test.localhost/404" })
topic = create_post.topic
get "/t/#{topic.slug}/#{topic.id}"
expect(response.body).to have_tag("link", with: { rel: "canonical", href: "http://test.localhost/t/#{topic.slug}/#{topic.id}" })
end
context "default locale" do
before do
SiteSetting.default_locale = :fr
sign_in(Fabricate(:user))
end
after do
I18n.reload!
end
context "with rate limits" do
before do
RateLimiter.clear_all!
RateLimiter.enable
end
after { RateLimiter.disable }
it "serves a LimitExceeded error in the preferred locale" do
SiteSetting.max_likes_per_day = 1
post1 = Fabricate(:post)
post2 = Fabricate(:post)
override = TranslationOverride.create(
locale: "fr",
translation_key: "rate_limiter.by_type.create_like",
value: "French LimitExceeded error message"
)
I18n.reload!
post "/post_actions.json", params: {
id: post1.id, post_action_type_id: PostActionType.types[:like]
}
expect(response.status).to eq(200)
post "/post_actions.json", params: {
id: post2.id, post_action_type_id: PostActionType.types[:like]
}
expect(response.status).to eq(429)
expect(response.parsed_body["errors"].first).to eq(override.value)
end
end
it "serves an InvalidParameters error with the default locale" do
override = TranslationOverride.create(
locale: "fr",
translation_key: "invalid_params",
value: "French InvalidParameters error message"
)
I18n.reload!
get "/search.json", params: { q: "hello\0hello" }
expect(response.status).to eq(400)
expect(response.parsed_body["errors"].first).to eq(override.value)
end
end
describe "set_locale" do
# Using /bootstrap.json because it returns a locale-dependent value
def headers(locale)
{ HTTP_ACCEPT_LANGUAGE: locale }
end
context "allow_user_locale disabled" do
context "accept-language header differs from default locale" do
before do
SiteSetting.allow_user_locale = false
SiteSetting.default_locale = "en"
end
context "with an anonymous user" do
it "uses the default locale" do
get "/bootstrap.json", headers: headers("fr")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("en.js")
end
end
context "with a logged in user" do
it "it uses the default locale" do
user = Fabricate(:user, locale: :fr)
sign_in(user)
get "/bootstrap.json", headers: headers("fr")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("en.js")
end
end
end
end
context "set_locale_from_accept_language_header enabled" do
context "accept-language header differs from default locale" do
before do
SiteSetting.allow_user_locale = true
SiteSetting.set_locale_from_accept_language_header = true
SiteSetting.default_locale = "en"
end
context "with an anonymous user" do
it "uses the locale from the headers" do
get "/bootstrap.json", headers: headers("fr")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("fr.js")
end
it "doesn't leak after requests" do
get "/bootstrap.json", headers: headers("fr")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("fr.js")
expect(I18n.locale.to_s).to eq(SiteSettings::DefaultsProvider::DEFAULT_LOCALE)
end
end
context "with a logged in user" do
let(:user) { Fabricate(:user, locale: :fr) }
before do
sign_in(user)
end
it "uses the user's preferred locale" do
get "/bootstrap.json", headers: headers("fr")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("fr.js")
end
it "serves a 404 page in the preferred locale" do
get "/missingroute", headers: headers("fr")
expect(response.status).to eq(404)
expected_title = I18n.t("page_not_found.title", locale: :fr)
expect(response.body).to include(CGI.escapeHTML(expected_title))
end
it "serves a RenderEmpty page in the preferred locale" do
get "/u/#{user.username}/preferences/interface"
expect(response.status).to eq(200)
expect(response.body).to have_tag('script', with: { src: "/assets/locales/fr.js" })
end
end
end
context "the preferred locale includes a region" do
it "returns the locale and region separated by an underscore" do
SiteSetting.allow_user_locale = true
SiteSetting.set_locale_from_accept_language_header = true
SiteSetting.default_locale = "en"
get "/bootstrap.json", headers: headers("zh-CN")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("zh_CN.js")
end
end
context 'accept-language header is not set' do
it 'uses the site default locale' do
SiteSetting.allow_user_locale = true
SiteSetting.default_locale = 'en'
get "/bootstrap.json", headers: headers("")
expect(response.status).to eq(200)
expect(response.parsed_body['bootstrap']['locale_script']).to end_with("en.js")
end
end
end
end
end