mastodon/spec/requests/cache_spec.rb

633 lines
19 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
module TestEndpoints
# Endpoints that do not include authorization-dependent results
# and should be cacheable no matter what.
ALWAYS_CACHED = %w(
/.well-known/host-meta
/.well-known/nodeinfo
/nodeinfo/2.0
/manifest
/custom.css
/actor
/api/v1/instance/extended_description
/api/v1/instance/rules
/api/v1/instance/peers
/api/v1/instance
/api/v2/instance
).freeze
# Endpoints that should be cachable when accessed anonymously but have a Vary
# on Cookie to prevent logged-in users from getting values from logged-out cache.
COOKIE_DEPENDENT_CACHABLE = %w(
/
/explore
/public
/about
/privacy-policy
/directory
/@alice
/@alice/110224538612341312
/deck/home
).freeze
# Endpoints that should be cachable when accessed anonymously but have a Vary
# on Authorization to prevent logged-in users from getting values from logged-out cache.
AUTHORIZATION_DEPENDENT_CACHABLE = %w(
/api/v1/accounts/lookup?acct=alice
/api/v1/statuses/110224538612341312
/api/v1/statuses/110224538612341312/context
/api/v1/polls/12345
/api/v1/trends/statuses
/api/v1/directory
).freeze
# Private status that should only be returned with to a valid signature from
# a specific user.
# Should never be cached.
REQUIRE_SIGNATURE = %w(
/users/alice/statuses/110224538643211312
).freeze
# Pages only available to logged-in users.
# Should never be cached.
REQUIRE_LOGIN = %w(
/settings/preferences/appearance
/settings/profile
/settings/featured_tags
/settings/export
/relationships
/filters
/statuses_cleanup
/auth/edit
/oauth/authorized_applications
/admin/dashboard
).freeze
# API endpoints only available to logged-in users.
# Should never be cached.
REQUIRE_TOKEN = %w(
/api/v1/announcements
/api/v1/timelines/home
/api/v1/notifications
/api/v1/bookmarks
/api/v1/favourites
/api/v1/follow_requests
/api/v1/conversations
/api/v1/statuses/110224538643211312
/api/v1/statuses/110224538643211312/context
/api/v1/lists
/api/v2/filters
).freeze
# Pages that are only shown to logged-out users, and should never get cached
# because of CSRF protection.
REQUIRE_LOGGED_OUT = %w(
/invite/abcdef
/auth/sign_in
/auth/sign_up
/auth/password/new
/auth/confirmation/new
).freeze
# Non-exhaustive list of endpoints that feature language-dependent results
# and thus need to have a Vary on Accept-Language
LANGUAGE_DEPENDENT = %w(
/
/explore
/about
/api/v1/trends/statuses
).freeze
module AuthorizedFetch
# Endpoints that require a signature with AUTHORIZED_FETCH and LIMITED_FEDERATION_MODE
# and thus should not be cached in those modes.
REQUIRE_SIGNATURE = %w(
/users/alice
).freeze
end
module DisabledAnonymousAPI
# Endpoints that require a signature with DISALLOW_UNAUTHENTICATED_API_ACCESS
# and thus should not be cached in this mode.
REQUIRE_TOKEN = %w(
/api/v1/custom_emojis
).freeze
end
end
describe 'Caching behavior' do
shared_examples 'cachable response' do |http_success: false|
it 'does not set cookies or set public cache control', :aggregate_failures do
expect(response.cookies).to be_empty
# expect(response.cache_control[:max_age]&.to_i).to be_positive
expect(response.cache_control[:public]).to be_truthy
expect(response.cache_control[:private]).to be_falsy
expect(response.cache_control[:no_store]).to be_falsy
expect(response.cache_control[:no_cache]).to be_falsy
expect(response).to have_http_status(200) if http_success
end
end
shared_examples 'non-cacheable response' do |http_success: false|
it 'sets private cache control' do
expect(response.cache_control[:private]).to be_truthy
expect(response.cache_control[:no_store]).to be_truthy
expect(response).to have_http_status(200) if http_success
end
end
shared_examples 'non-cacheable error' do
it 'does not return HTTP success and does not have cache headers', :aggregate_failures do
expect(response).to_not have_http_status(200)
expect(response.cache_control[:public]).to be_falsy
end
end
shared_examples 'language-dependent' do
it 'has a Vary on Accept-Language' do
expect(response_vary_headers).to include('accept-language')
end
end
# Enable CSRF protection like it is in production, as it can cause cookies
# to be set and thus mess with cache.
around do |example|
old = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
example.run
ActionController::Base.allow_forgery_protection = old
end
let(:alice) { Fabricate(:account, username: 'alice') }
let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Moderator')) }
before do
status = Fabricate(:status, account: alice, id: '110224538612341312')
Fabricate(:status, account: alice, id: '110224538643211312', visibility: :private)
Fabricate(:invite, code: 'abcdef')
Fabricate(:poll, status: status, account: alice, id: '12345')
user.account.follow!(alice)
end
context 'when anonymously accessed' do
describe '/users/alice' do
it 'redirects with proper cache header', :aggregate_failures do
get '/users/alice'
expect(response).to redirect_to('/@alice')
expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept')
end
end
TestEndpoints::ALWAYS_CACHED.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'cachable response'
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::COOKIE_DEPENDENT_CACHABLE.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'cachable response'
it 'has a Vary on Cookie' do
expect(response_vary_headers).to include('cookie')
end
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'cachable response'
it 'has a Vary on Authorization' do
expect(response_vary_headers).to include('authorization')
end
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable response'
end
end
(TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::REQUIRE_LOGIN + TestEndpoints::REQUIRE_TOKEN).each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable error'
end
end
describe '/api/v1/instance/domain_blocks' do
before do
Setting.show_domain_blocks = show_domain_blocks
get '/api/v1/instance/domain_blocks'
end
context 'when set to be publicly-available' do
let(:show_domain_blocks) { 'all' }
it_behaves_like 'cachable response'
end
context 'when allowed for local users only' do
let(:show_domain_blocks) { 'users' }
it_behaves_like 'non-cacheable error'
end
context 'when disabled' do
let(:show_domain_blocks) { 'disabled' }
it_behaves_like 'non-cacheable error'
end
end
end
context 'when logged in' do
before do
sign_in user, scope: :user
# Unfortunately, devise's `sign_in` helper causes the `session` to be
# loaded in the next request regardless of whether it's actually accessed
# by the client code.
#
# So, we make an extra query to clear issue a session cookie instead.
#
# A less resource-intensive way to deal with that would be to generate the
# session cookie manually, but this seems pretty involved.
get '/'
end
TestEndpoints::ALWAYS_CACHED.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'cachable response'
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::COOKIE_DEPENDENT_CACHABLE.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable response'
it 'has a Vary on Cookie' do
expect(response_vary_headers).to include('cookie')
end
end
end
TestEndpoints::REQUIRE_LOGIN.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable response', http_success: true
end
end
TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable error'
end
end
end
context 'with an auth token' do
let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
TestEndpoints::ALWAYS_CACHED.each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
end
it_behaves_like 'cachable response'
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
end
it_behaves_like 'non-cacheable response'
it 'has a Vary on Authorization' do
expect(response_vary_headers).to include('authorization')
end
end
end
(TestEndpoints::REQUIRE_LOGGED_OUT + TestEndpoints::REQUIRE_TOKEN).each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
end
it_behaves_like 'non-cacheable response', http_success: true
end
end
describe '/api/v1/instance/domain_blocks' do
before do
Setting.show_domain_blocks = show_domain_blocks
get '/api/v1/instance/domain_blocks', headers: { 'Authorization' => "Bearer #{token.token}" }
end
context 'when set to be publicly-available' do
let(:show_domain_blocks) { 'all' }
it_behaves_like 'cachable response'
end
context 'when allowed for local users only' do
let(:show_domain_blocks) { 'users' }
it_behaves_like 'non-cacheable response', http_success: true
end
context 'when disabled' do
let(:show_domain_blocks) { 'disabled' }
it_behaves_like 'non-cacheable error'
end
end
end
context 'with a Signature header' do
let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
let(:dummy_signature) { 'dummy-signature' }
before do
remote_actor.follow!(alice)
end
describe '/actor' do
before do
get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'cachable response', http_success: true
end
TestEndpoints::REQUIRE_SIGNATURE.each do |endpoint|
describe endpoint do
before do
get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'non-cacheable response', http_success: true
end
end
end
context 'when enabling AUTHORIZED_FETCH mode' do
around do |example|
ClimateControl.modify AUTHORIZED_FETCH: 'true' do
example.run
end
end
context 'when not providing a Signature' do
describe '/actor' do
before do
get '/actor', headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'cachable response', http_success: true
end
(TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'non-cacheable error'
end
end
end
context 'when providing a Signature' do
let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
let(:dummy_signature) { 'dummy-signature' }
before do
remote_actor.follow!(alice)
end
describe '/actor' do
before do
get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'cachable response', http_success: true
end
(TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
describe endpoint do
before do
get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'non-cacheable response', http_success: true
end
end
end
end
context 'when enabling LIMITED_FEDERATION_MODE mode' do
around do |example|
ClimateControl.modify LIMITED_FEDERATION_MODE: 'true' do
old_limited_federation_mode = Rails.configuration.x.limited_federation_mode
Rails.configuration.x.limited_federation_mode = true
example.run
Rails.configuration.x.limited_federation_mode = old_limited_federation_mode
end
end
context 'when not providing a Signature' do
describe '/actor' do
before do
get '/actor', headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'cachable response', http_success: true
end
(TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'non-cacheable error'
end
end
end
context 'when providing a Signature from an allowed domain' do
let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
let(:dummy_signature) { 'dummy-signature' }
before do
DomainAllow.create!(domain: remote_actor.domain)
remote_actor.follow!(alice)
end
describe '/actor' do
before do
get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'cachable response', http_success: true
end
(TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
describe endpoint do
before do
get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'non-cacheable response', http_success: true
end
end
end
context 'when providing a Signature from a non-allowed domain' do
let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
let(:dummy_signature) { 'dummy-signature' }
describe '/actor' do
before do
get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'cachable response', http_success: true
end
(TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
describe endpoint do
before do
get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
end
it_behaves_like 'non-cacheable error'
end
end
end
end
context 'when enabling DISALLOW_UNAUTHENTICATED_API_ACCESS' do
around do |example|
ClimateControl.modify DISALLOW_UNAUTHENTICATED_API_ACCESS: 'true' do
example.run
end
end
context 'when anonymously accessed' do
TestEndpoints::ALWAYS_CACHED.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'cachable response'
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable response'
end
end
(TestEndpoints::REQUIRE_TOKEN + TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE + TestEndpoints::DisabledAnonymousAPI::REQUIRE_TOKEN).each do |endpoint|
describe endpoint do
before { get endpoint }
it_behaves_like 'non-cacheable error'
end
end
end
context 'with an auth token' do
let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
TestEndpoints::ALWAYS_CACHED.each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
end
it_behaves_like 'cachable response'
it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
end
end
TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
end
it_behaves_like 'non-cacheable response'
it 'has a Vary on Authorization' do
expect(response_vary_headers).to include('authorization')
end
end
end
(TestEndpoints::REQUIRE_LOGGED_OUT + TestEndpoints::REQUIRE_TOKEN + TestEndpoints::DisabledAnonymousAPI::REQUIRE_TOKEN).each do |endpoint|
describe endpoint do
before do
get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
end
it_behaves_like 'non-cacheable response', http_success: true
end
end
end
end
private
def response_vary_headers
response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }
end
end