discourse/spec/requests/application_controller_spec.rb
Osama Sayegh 7bd3986b21
FEATURE: Replace Crawl-delay directive with proper rate limiting (#15131)
We have a couple of site setting, `slow_down_crawler_user_agents` and `slow_down_crawler_rate`, that are meant to allow site owners to signal to specific crawlers that they're crawling the site too aggressively and that they should slow down.

When a crawler is added to the `slow_down_crawler_user_agents` setting, Discourse currently adds a `Crawl-delay` directive for that crawler in `/robots.txt`. Unfortunately, many crawlers don't support the `Crawl-delay` directive in `/robots.txt` which leaves the site owners no options if a crawler is crawling the site too aggressively.

This PR replaces the `Crawl-delay` directive with proper rate limiting for crawlers added to the `slow_down_crawler_user_agents` list. On every request made by a non-logged in user, Discourse will check the User Agent string and if it contains one of the values of the `slow_down_crawler_user_agents` list, Discourse will only allow 1 request every N seconds for that User Agent (N is the value of the `slow_down_crawler_rate` setting) and the rest of requests made within the same interval will get a 429 response. 

The `slow_down_crawler_user_agents` setting becomes quite dangerous with this PR since it could rate limit lots if not all of anonymous traffic if the setting is not used appropriately. So to protect against this scenario, we've added a couple of new validations to the setting when it's changed:

1) each value added to setting must 3 characters or longer
2) each value cannot be a substring of tokens found in popular browser User Agent. The current list of prohibited values is: apple, windows, linux, ubuntu, gecko, firefox, chrome, safari, applewebkit, webkit, mozilla, macintosh, khtml, intel, osx, os x, iphone, ipad and mac.
2021-11-30 12:55:25 +03:00

1003 lines
32 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.discourse_connect_url = 'http://someurl.com'
SiteSetting.enable_discourse_connect = 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 auth_immediately is disabled" do
SiteSetting.auth_immediately = false
SiteSetting.discourse_connect_url = 'http://someurl.com'
SiteSetting.enable_discourse_connect = true
get "/"
expect(response).to redirect_to("/login")
end
it "should not redirect to authenticator when auth_immediately is disabled" do
SiteSetting.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}\"")
expect(response.headers["Set-Cookie"]).to include("authentication_data=;") # Delete cookie
end
it 'deletes authentication data cookie even if already authenticated' do
sign_in(Fabricate(:user))
cookies['authentication_data'] = "someauthenticationdata"
get '/'
expect(response.status).to eq(200)
expect(response.body).not_to include("data-authentication-data=")
expect(response.headers["Set-Cookie"]).to include("authentication_data=;") # Delete cookie
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 INTERMITTENT 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["errors"].first).to include("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_id).to eq(theme.id)
theme.update_attribute(:user_selectable, false)
get "/"
expect(response.status).to eq(200)
expect(controller.theme_id).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_id).to eq(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_id).to eq(theme2.id)
end
it "can be overridden with preview_theme_id param" do
sign_in(admin)
cookies['theme_ids'] = "#{theme.id}|#{admin.user_option.theme_key_seq}"
get "/", params: { preview_theme_id: theme2.id }
expect(response.status).to eq(200)
expect(controller.theme_id).to eq(theme2.id)
get "/", params: { preview_theme_id: non_selectable_theme.id }
expect(controller.theme_id).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_id).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_id).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
it 'when GTM is enabled it adds the same nonce to the policy and the GTM tag' do
SiteSetting.content_security_policy = true
SiteSetting.gtm_container_id = 'GTM-ABCDEF'
get '/latest'
nonce = ApplicationHelper.google_tag_manager_nonce
expect(response.headers).to include('Content-Security-Policy')
script_src = parse(response.headers['Content-Security-Policy'])['script-src']
expect(script_src.to_s).to include(nonce)
expect(response.body).to include(nonce)
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
it "adds a noindex header if non-canonical indexing is disabled" do
SiteSetting.allow_indexing_non_canonical_urls = false
get '/'
expect(response.headers['X-Robots-Tag']).to be_nil
get '/latest'
expect(response.headers['X-Robots-Tag']).to be_nil
get '/categories'
expect(response.headers['X-Robots-Tag']).to be_nil
topic = create_post.topic
get "/t/#{topic.slug}/#{topic.id}"
expect(response.headers['X-Robots-Tag']).to be_nil
post = create_post(topic_id: topic.id)
get "/t/#{topic.slug}/#{topic.id}/2"
expect(response.headers['X-Robots-Tag']).to eq('noindex')
20.times do
create_post(topic_id: topic.id)
end
get "/t/#{topic.slug}/#{topic.id}/21"
expect(response.headers['X-Robots-Tag']).to eq('noindex')
get "/t/#{topic.slug}/#{topic.id}?page=2"
expect(response.headers['X-Robots-Tag']).to be_nil
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
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
describe 'vary header' do
it 'includes Vary:Accept on all requests where format is not explicit' do
# Rails default behaviour - include Vary:Accept when Accept is supplied
get "/latest", headers: { "Accept" => "application/json" }
expect(response.status).to eq(200)
expect(response.headers["Vary"]).to eq("Accept")
# Discourse additional behaviour (see lib/vary_header.rb)
# Include Vary:Accept even when Accept is not supplied
get "/latest"
expect(response.status).to eq(200)
expect(response.headers["Vary"]).to eq("Accept")
# Not needed, because the path 'format' parameter overrides the Accept header
get "/latest.json"
expect(response.status).to eq(200)
expect(response.headers["Vary"]).to eq(nil)
end
end
describe "Discourse-Rate-Limit-Error-Code header" do
fab!(:admin) { Fabricate(:admin) }
before do
RateLimiter.clear_all!
RateLimiter.enable
end
it "is included when API key is rate limited" do
global_setting :max_admin_api_reqs_per_minute, 1
api_key = ApiKey.create!(user_id: admin.id).key
get "/latest.json", headers: {
"Api-Key": api_key,
"Api-Username": admin.username
}
expect(response.status).to eq(200)
get "/latest.json", headers: {
"Api-Key": api_key,
"Api-Username": admin.username
}
expect(response.status).to eq(429)
expect(response.headers["Discourse-Rate-Limit-Error-Code"]).to eq("admin_api_key_rate_limit")
end
it "is included when user API key is rate limited" do
global_setting :max_user_api_reqs_per_minute, 1
user_api_key = UserApiKey.create!(
user_id: admin.id,
client_id: "",
application_name: "discourseapp"
)
user_api_key.scopes = UserApiKeyScope.all_scopes.keys.map do |name|
UserApiKeyScope.create!(name: name, user_api_key_id: user_api_key.id)
end
user_api_key.save!
get "/session/current.json", headers: {
"User-Api-Key": user_api_key.key,
}
expect(response.status).to eq(200)
get "/session/current.json", headers: {
"User-Api-Key": user_api_key.key,
}
expect(response.status).to eq(429)
expect(
response.headers["Discourse-Rate-Limit-Error-Code"]
).to eq("user_api_key_limiter_60_secs")
global_setting :max_user_api_reqs_per_minute, 100
global_setting :max_user_api_reqs_per_day, 1
get "/session/current.json", headers: {
"User-Api-Key": user_api_key.key,
}
expect(response.status).to eq(429)
expect(
response.headers["Discourse-Rate-Limit-Error-Code"]
).to eq("user_api_key_limiter_1_day")
end
end
describe "crawlers in slow_down_crawler_user_agents site setting" do
before do
RateLimiter.enable
RateLimiter.clear_all!
end
it "are rate limited" do
SiteSetting.slow_down_crawler_rate = 128
SiteSetting.slow_down_crawler_user_agents = "badcrawler|problematiccrawler"
now = Time.zone.now
freeze_time now
get "/", headers: {
"HTTP_USER_AGENT" => "iam badcrawler"
}
expect(response.status).to eq(200)
get "/", headers: {
"HTTP_USER_AGENT" => "iam badcrawler"
}
expect(response.status).to eq(429)
expect(response.headers["Retry-After"]).to eq("128")
get "/", headers: {
"HTTP_USER_AGENT" => "iam problematiccrawler"
}
expect(response.status).to eq(200)
get "/", headers: {
"HTTP_USER_AGENT" => "iam problematiccrawler"
}
expect(response.status).to eq(429)
expect(response.headers["Retry-After"]).to eq("128")
freeze_time now + 100.seconds
get "/", headers: {
"HTTP_USER_AGENT" => "iam badcrawler"
}
expect(response.status).to eq(429)
expect(response.headers["Retry-After"]).to eq("28")
get "/", headers: {
"HTTP_USER_AGENT" => "iam problematiccrawler"
}
expect(response.status).to eq(429)
expect(response.headers["Retry-After"]).to eq("28")
freeze_time now + 150.seconds
get "/", headers: {
"HTTP_USER_AGENT" => "iam badcrawler"
}
expect(response.status).to eq(200)
get "/", headers: {
"HTTP_USER_AGENT" => "iam problematiccrawler"
}
expect(response.status).to eq(200)
end
end
end