diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a19fcc7a0a..deb60f69bc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base include UserTrackingConcern include SessionTrackingConcern include CacheConcern + include ErrorResponses include PreloadingConcern include DomainControlHelper include DatabaseHelper @@ -23,21 +24,6 @@ class ApplicationController < ActionController::Base helper_method :limited_federation_mode? helper_method :skip_csrf_meta_tags? - rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request - rescue_from Mastodon::NotPermittedError, with: :forbidden - rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found - rescue_from ActionController::UnknownFormat, with: :not_acceptable - rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content - rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests - - rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) - rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable - - rescue_from Seahorse::Client::NetworkingError do |e| - Rails.logger.warn "Storage server error: #{e}" - service_unavailable - end - before_action :check_self_destruct! before_action :store_referrer, except: :raise_not_found, if: :devise_controller? @@ -118,42 +104,6 @@ class ApplicationController < ActionController::Base ActiveModel::Type::Boolean.new.cast(params[key]) end - def forbidden - respond_with_error(403) - end - - def not_found - respond_with_error(404) - end - - def gone - respond_with_error(410) - end - - def unprocessable_content - respond_with_error(422) - end - - def not_acceptable - respond_with_error(406) - end - - def bad_request - respond_with_error(400) - end - - def internal_server_error - respond_with_error(500) - end - - def service_unavailable - respond_with_error(503) - end - - def too_many_requests - respond_with_error(429) - end - def single_user_mode? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists? end @@ -178,13 +128,6 @@ class ApplicationController < ActionController::Base @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? end - def respond_with_error(code) - respond_to do |format| - format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } - format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } - end - end - def check_self_destruct! return unless self_destruct? diff --git a/app/controllers/concerns/error_responses.rb b/app/controllers/concerns/error_responses.rb new file mode 100644 index 0000000000..402ade0066 --- /dev/null +++ b/app/controllers/concerns/error_responses.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ErrorResponses + extend ActiveSupport::Concern + + included do + rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content + rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request + rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActionController::UnknownFormat, with: :not_acceptable + rescue_from Mastodon::NotPermittedError, with: :forbidden + rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable + rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) + + rescue_from Seahorse::Client::NetworkingError do |e| + Rails.logger.warn "Storage server error: #{e}" + service_unavailable + end + end + + protected + + def bad_request + respond_with_error(400) + end + + def forbidden + respond_with_error(403) + end + + def gone + respond_with_error(410) + end + + def internal_server_error + respond_with_error(500) + end + + def not_acceptable + respond_with_error(406) + end + + def not_found + respond_with_error(404) + end + + def service_unavailable + respond_with_error(503) + end + + def too_many_requests + respond_with_error(429) + end + + def unprocessable_content + respond_with_error(422) + end + + private + + def respond_with_error(code) + respond_to do |format| + format.any { render "errors/#{code}", layout: 'error', formats: [:html], status: code } + format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } + end + end +end diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb index 9e2483983d..c86862c0a3 100644 --- a/app/lib/activitypub/activity/add.rb +++ b/app/lib/activitypub/activity/add.rb @@ -12,6 +12,9 @@ class ActivityPub::Activity::Add < ActivityPub::Activity else add_featured end + else + @collection = @account.collections.find_by(uri: @json['target']) + add_collection_item if @collection && Mastodon::Feature.collections_federation_enabled? end end @@ -30,4 +33,8 @@ class ActivityPub::Activity::Add < ActivityPub::Activity FeaturedTag.create!(account: @account, name: name) if name.present? end + + def add_collection_item + ActivityPub::ProcessFeaturedItemService.new.call(@collection, @object) + end end diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb index e113b3b522..1cc8d80e62 100644 --- a/app/models/collection_item.rb +++ b/app/models/collection_item.rb @@ -29,7 +29,7 @@ class CollectionItem < ApplicationRecord validates :position, numericality: { only_integer: true, greater_than: 0 } validates :activity_uri, presence: true, if: :local_item_with_remote_account? - validates :approval_uri, absence: true, unless: :local? + validates :approval_uri, presence: true, unless: -> { local? || account&.local? } validates :account, presence: true, if: :accepted? validates :object_uri, presence: true, if: -> { account.nil? } validates :uri, presence: true, if: :remote? diff --git a/app/services/activitypub/process_featured_item_service.rb b/app/services/activitypub/process_featured_item_service.rb new file mode 100644 index 0000000000..24f3bbfaed --- /dev/null +++ b/app/services/activitypub/process_featured_item_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessFeaturedItemService + include JsonLdHelper + include Lockable + include Redisable + + def call(collection, uri_or_object) + item_json = uri_or_object.is_a?(String) ? fetch_resource(uri_or_object, true) : uri_or_object + return if non_matching_uri_hosts?(collection.uri, item_json['id']) + + with_redis_lock("collection_item:#{item_json['id']}") do + return if collection.collection_items.exists?(uri: item_json['id']) + + @collection_item = collection.collection_items.create!( + uri: item_json['id'], + object_uri: item_json['featuredObject'], + approval_uri: item_json['featureAuthorization'] + ) + + verify_authorization! + + @collection_item + end + end + + private + + def verify_authorization! + ActivityPub::VerifyFeaturedItemService.new.call(@collection_item) + rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS + ActivityPub::VerifyFeaturedItemWorker.perform_in(rand(30..600).seconds, @collection_item.id) + end +end diff --git a/app/services/activitypub/verify_featured_item_service.rb b/app/services/activitypub/verify_featured_item_service.rb new file mode 100644 index 0000000000..f3dcccf4a0 --- /dev/null +++ b/app/services/activitypub/verify_featured_item_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class ActivityPub::VerifyFeaturedItemService + include JsonLdHelper + + def call(collection_item) + @collection_item = collection_item + @authorization = fetch_resource(@collection_item.approval_uri, true, raise_on_error: :temporary) + + if @authorization.nil? + @collection_item.update!(state: :rejected) + return + end + + return if non_matching_uri_hosts?(@collection_item.approval_uri, @authorization['interactionTarget']) + return unless matching_type? && matching_collection_uri? + + account = Account.where(uri: @collection_item.object_uri).first + account ||= ActivityPub::FetchRemoteAccountService.new.call(@collection_item.object_uri) + return if account.blank? + + @collection_item.update!(account:, state: :accepted) + end + + private + + def matching_type? + supported_context?(@authorization) && equals_or_includes?(@authorization['type'], 'FeatureAuthorization') + end + + def matching_collection_uri? + @collection_item.collection.uri == @authorization['interactingObject'] + end +end diff --git a/app/workers/activitypub/verify_featured_item_worker.rb b/app/workers/activitypub/verify_featured_item_worker.rb new file mode 100644 index 0000000000..6eda194717 --- /dev/null +++ b/app/workers/activitypub/verify_featured_item_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ActivityPub::VerifyFeaturedItemWorker + include Sidekiq::Worker + include ExponentialBackoff + include JsonLdHelper + + sidekiq_options queue: 'pull', retry: 5 + + def perform(collection_item_id) + collection_item = CollectionItem.find(collection_item_id) + + ActivityPub::VerifyFeaturedItemService.new.call(collection_item) + rescue ActiveRecord::RecordNotFound + # Do nothing + nil + rescue Mastodon::UnexpectedResponseError => e + raise e unless response_error_unsalvageable?(e.response) + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index d93e3c91be..e331de2511 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -6,52 +6,21 @@ RSpec.describe ApplicationController do render_views controller do - def success - head 200 - end - - def routing_error - raise ActionController::RoutingError, '' - end - - def record_not_found - raise ActiveRecord::RecordNotFound, '' - end - - def invalid_authenticity_token - raise ActionController::InvalidAuthenticityToken, '' - end - end - - shared_examples 'error response' do |code| - it "returns http #{code} for http and renders template" do - subject - - expect(response) - .to have_http_status(code) - expect(response.parsed_body) - .to have_css('body[class=error]') - expect(response.parsed_body.css('h1').to_s) - .to include(error_content(code)) - end - - def error_content(code) - if code == 422 - I18n.t('errors.422.content') - else - I18n.t("errors.#{code}") - end - end + def success = head(200) end context 'with a forgery' do - subject do + before do ActionController::Base.allow_forgery_protection = true routes.draw { post 'success' => 'anonymous#success' } - post 'success' end - it_behaves_like 'error response', 422 + it 'responds with 422 and error page' do + post 'success' + + expect(response) + .to have_http_status(422) + end end describe 'helper_method :current_account' do @@ -124,33 +93,6 @@ RSpec.describe ApplicationController do end end - context 'with ActionController::RoutingError' do - subject do - routes.draw { get 'routing_error' => 'anonymous#routing_error' } - get 'routing_error' - end - - it_behaves_like 'error response', 404 - end - - context 'with ActiveRecord::RecordNotFound' do - subject do - routes.draw { get 'record_not_found' => 'anonymous#record_not_found' } - get 'record_not_found' - end - - it_behaves_like 'error response', 404 - end - - context 'with ActionController::InvalidAuthenticityToken' do - subject do - routes.draw { get 'invalid_authenticity_token' => 'anonymous#invalid_authenticity_token' } - get 'invalid_authenticity_token' - end - - it_behaves_like 'error response', 422 - end - describe 'before_action :check_suspension' do before do routes.draw { get 'success' => 'anonymous#success' } @@ -180,64 +122,4 @@ RSpec.describe ApplicationController do expect { controller.raise_not_found }.to raise_error(ActionController::RoutingError, 'No route matches unmatched') end end - - describe 'forbidden' do - controller do - def route_forbidden - forbidden - end - end - - subject do - routes.draw { get 'route_forbidden' => 'anonymous#route_forbidden' } - get 'route_forbidden' - end - - it_behaves_like 'error response', 403 - end - - describe 'not_found' do - controller do - def route_not_found - not_found - end - end - - subject do - routes.draw { get 'route_not_found' => 'anonymous#route_not_found' } - get 'route_not_found' - end - - it_behaves_like 'error response', 404 - end - - describe 'gone' do - controller do - def route_gone - gone - end - end - - subject do - routes.draw { get 'route_gone' => 'anonymous#route_gone' } - get 'route_gone' - end - - it_behaves_like 'error response', 410 - end - - describe 'unprocessable_content' do - controller do - def route_unprocessable_content - unprocessable_content - end - end - - subject do - routes.draw { get 'route_unprocessable_content' => 'anonymous#route_unprocessable_content' } - get 'route_unprocessable_content' - end - - it_behaves_like 'error response', 422 - end end diff --git a/spec/controllers/concerns/error_responses_spec.rb b/spec/controllers/concerns/error_responses_spec.rb new file mode 100644 index 0000000000..678d876518 --- /dev/null +++ b/spec/controllers/concerns/error_responses_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ErrorResponses do + render_views + + shared_examples 'error response' do |code| + before { routes.draw { get 'show' => 'anonymous#show' } } + + it "returns http #{code} and renders error template" do + get 'show' + + expect(response) + .to have_http_status(code) + expect(response.parsed_body) + .to have_css('body[class=error]') + .and have_css('h1', text: error_content(code)) + end + + def error_content(code) + I18n.t("errors.#{code}") + .then { |value| I18n.t("errors.#{code}.content") if value.is_a?(Hash) } + end + end + + describe 'bad_request' do + controller(ApplicationController) do + def show = bad_request + end + + it_behaves_like 'error response', 400 + end + + describe 'forbidden' do + controller(ApplicationController) do + def show = forbidden + end + + it_behaves_like 'error response', 403 + end + + describe 'gone' do + controller(ApplicationController) do + def show = gone + end + + it_behaves_like 'error response', 410 + end + + describe 'internal_server_error' do + controller(ApplicationController) do + def show = internal_server_error + end + + it_behaves_like 'error response', 500 + end + + describe 'not_acceptable' do + controller(ApplicationController) do + def show = not_acceptable + end + + it_behaves_like 'error response', 406 + end + + describe 'not_found' do + controller(ApplicationController) do + def show = not_found + end + + it_behaves_like 'error response', 404 + end + + describe 'service_unavailable' do + controller(ApplicationController) do + def show = service_unavailable + end + + it_behaves_like 'error response', 503 + end + + describe 'too_many_requests' do + controller(ApplicationController) do + def show = too_many_requests + end + + it_behaves_like 'error response', 429 + end + + describe 'unprocessable_content' do + controller(ApplicationController) do + def show = unprocessable_content + end + + it_behaves_like 'error response', 422 + end + + context 'with ActionController::RoutingError' do + controller(ApplicationController) do + def show + raise ActionController::RoutingError, '' + end + end + + it_behaves_like 'error response', 404 + end + + context 'with ActiveRecord::RecordNotFound' do + controller(ApplicationController) do + def show + raise ActiveRecord::RecordNotFound, '' + end + end + + it_behaves_like 'error response', 404 + end + + context 'with ActionController::InvalidAuthenticityToken' do + controller(ApplicationController) do + def show + raise ActionController::InvalidAuthenticityToken, '' + end + end + + it_behaves_like 'error response', 422 + end +end diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb index c0abd9f393..0f8ce53cfb 100644 --- a/spec/lib/activitypub/activity/add_spec.rb +++ b/spec/lib/activitypub/activity/add_spec.rb @@ -3,78 +3,118 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Add do - let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured', domain: 'example.com') } - let(:status) { Fabricate(:status, account: sender, visibility: :private) } + context 'when the target is the featured collection' do + let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured', domain: 'example.com') } + let(:status) { Fabricate(:status, account: sender, visibility: :private) } - let(:json) do - { - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'foo', - type: 'Add', - actor: ActivityPub::TagManager.instance.uri_for(sender), - object: ActivityPub::TagManager.instance.uri_for(status), - target: sender.featured_collection_url, - }.with_indifferent_access - end - - describe '#perform' do - subject { described_class.new(json, sender) } - - it 'creates a pin' do - subject.perform - expect(sender.pinned?(status)).to be true + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Add', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + target: sender.featured_collection_url, + }.with_indifferent_access end - context 'when status was not known before' do - let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) } + describe '#perform' do + subject { described_class.new(json, sender) } - let(:json) do - { - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'foo', - type: 'Add', - actor: ActivityPub::TagManager.instance.uri_for(sender), - object: 'https://example.com/unknown', - target: sender.featured_collection_url, - }.with_indifferent_access + it 'creates a pin' do + subject.perform + expect(sender.pinned?(status)).to be true end - before do - allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_stub) - end + context 'when status was not known before' do + let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Add', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://example.com/unknown', + target: sender.featured_collection_url, + }.with_indifferent_access + end - context 'when there is a local follower' do before do - account = Fabricate(:account) - account.follow!(sender) + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_stub) end - it 'fetches the status and pins it' do - allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil, **| - expect(uri).to eq 'https://example.com/unknown' - expect(id).to be true - expect(on_behalf_of&.following?(sender)).to be true - status + context 'when there is a local follower' do + before do + account = Fabricate(:account) + account.follow!(sender) end - subject.perform - expect(service_stub).to have_received(:call) - expect(sender.pinned?(status)).to be true - end - end - context 'when there is no local follower' do - it 'tries to fetch the status' do - allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil, **| - expect(uri).to eq 'https://example.com/unknown' - expect(id).to be true - expect(on_behalf_of).to be_nil - nil + it 'fetches the status and pins it' do + allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil, **| + expect(uri).to eq 'https://example.com/unknown' + expect(id).to be true + expect(on_behalf_of&.following?(sender)).to be true + status + end + subject.perform + expect(service_stub).to have_received(:call) + expect(sender.pinned?(status)).to be true + end + end + + context 'when there is no local follower' do + it 'tries to fetch the status' do + allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil, **| + expect(uri).to eq 'https://example.com/unknown' + expect(id).to be true + expect(on_behalf_of).to be_nil + nil + end + subject.perform + expect(service_stub).to have_received(:call) + expect(sender.pinned?(status)).to be false end - subject.perform - expect(service_stub).to have_received(:call) - expect(sender.pinned?(status)).to be false end end end end + + context 'when the target is a collection', feature: :collections_federation do + subject { described_class.new(activity_json, collection.account) } + + let(:collection) { Fabricate(:remote_collection) } + let(:featured_item_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://other.example.com/featured_item/1', + 'type' => 'FeaturedItem', + 'featuredObject' => 'https://example.com/actor/1', + 'featuredObjectType' => 'Person', + 'featureAuthorization' => 'https://example.com/auth/1', + } + end + let(:activity_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Add', + 'actor' => collection.account.uri, + 'target' => collection.uri, + 'object' => featured_item_json, + } + end + let(:stubbed_service) do + instance_double(ActivityPub::ProcessFeaturedItemService, call: true) + end + + before do + allow(ActivityPub::ProcessFeaturedItemService).to receive(:new).and_return(stubbed_service) + end + + it 'determines the correct collection and calls the service' do + subject.perform + + expect(stubbed_service).to have_received(:call).with(collection, featured_item_json) + end + end end diff --git a/spec/models/collection_item_spec.rb b/spec/models/collection_item_spec.rb index e4905535cf..e8be8c260b 100644 --- a/spec/models/collection_item_spec.rb +++ b/spec/models/collection_item_spec.rb @@ -21,9 +21,13 @@ RSpec.describe CollectionItem do let(:remote_collection) { Fabricate.build(:collection, local: false) } - it { is_expected.to validate_absence_of(:approval_uri) } - it { is_expected.to validate_presence_of(:uri) } + + context 'when account is not present' do + subject { Fabricate.build(:collection_item, collection: remote_collection, account: nil) } + + it { is_expected.to validate_presence_of(:approval_uri) } + end end context 'when account is not present' do diff --git a/spec/services/activitypub/process_featured_item_service_spec.rb b/spec/services/activitypub/process_featured_item_service_spec.rb new file mode 100644 index 0000000000..dab26f846b --- /dev/null +++ b/spec/services/activitypub/process_featured_item_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessFeaturedItemService do + subject { described_class.new } + + let(:collection) { Fabricate(:remote_collection, uri: 'https://other.example.com/collection/1') } + let(:featured_item_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://other.example.com/featured_item/1', + 'type' => 'FeaturedItem', + 'featuredObject' => 'https://example.com/actor/1', + 'featuredObjectType' => 'Person', + 'featureAuthorization' => 'https://example.com/auth/1', + } + end + let(:stubbed_service) do + instance_double(ActivityPub::VerifyFeaturedItemService, call: true) + end + + before do + allow(ActivityPub::VerifyFeaturedItemService).to receive(:new).and_return(stubbed_service) + end + + shared_examples 'non-matching URIs' do + context "when the item's URI does not match the collection's" do + let(:collection) { Fabricate(:remote_collection) } + + it 'does not create a collection item and returns `nil`' do + expect do + expect(subject.call(collection, object)).to be_nil + end.to_not change(CollectionItem, :count) + end + end + end + + context 'when the collection item is inlined' do + let(:object) { featured_item_json } + + it_behaves_like 'non-matching URIs' + + it 'creates and verifies the item' do + expect { subject.call(collection, object) }.to change(collection.collection_items, :count).by(1) + + expect(stubbed_service).to have_received(:call) + + new_item = collection.collection_items.last + expect(new_item.object_uri).to eq 'https://example.com/actor/1' + expect(new_item.approval_uri).to eq 'https://example.com/auth/1' + end + end + + context 'when only the id of the collection item is given' do + let(:object) { featured_item_json['id'] } + let(:featured_item_request) do + stub_request(:get, object) + .to_return_json( + status: 200, + body: featured_item_json, + headers: { 'Content-Type' => 'application/activity+json' } + ) + end + + before do + featured_item_request + end + + it_behaves_like 'non-matching URIs' + + it 'fetches the collection item' do + expect { subject.call(collection, object) }.to change(collection.collection_items, :count).by(1) + + expect(featured_item_request).to have_been_requested + + new_item = collection.collection_items.last + expect(new_item.object_uri).to eq 'https://example.com/actor/1' + expect(new_item.approval_uri).to eq 'https://example.com/auth/1' + end + end +end diff --git a/spec/services/activitypub/verify_featured_item_service_spec.rb b/spec/services/activitypub/verify_featured_item_service_spec.rb new file mode 100644 index 0000000000..5976ffeffc --- /dev/null +++ b/spec/services/activitypub/verify_featured_item_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::VerifyFeaturedItemService do + subject { described_class.new } + + let(:collection) { Fabricate(:remote_collection) } + let(:collection_item) do + Fabricate(:collection_item, + collection:, + account: nil, + state: :pending, + uri: 'https://other.example.com/items/1', + object_uri: 'https://example.com/actor/1', + approval_uri: verification_json['id']) + end + let(:verification_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'FeatureAuthorization', + 'id' => 'https://example.com/auth/1', + 'interactionTarget' => 'https://example.com/actor/1', + 'interactingObject' => collection.uri, + } + end + let(:verification_request) do + stub_request(:get, 'https://example.com/auth/1') + .to_return_json( + status: 200, + body: verification_json, + headers: { 'Content-Type' => 'application/activity+json' } + ) + end + let(:featured_account) { Fabricate(:remote_account, uri: 'https://example.com/actor/1') } + + before { verification_request } + + context 'when the authorization can be verified' do + context 'when the featured account is known' do + before { featured_account } + + it 'verifies and creates the item' do + subject.call(collection_item) + + expect(verification_request).to have_been_requested + + expect(collection_item.account_id).to eq featured_account.id + expect(collection_item).to be_accepted + end + end + + context 'when the featured account is not known' do + let(:stubbed_service) { instance_double(ActivityPub::FetchRemoteAccountService) } + + before do + allow(stubbed_service).to receive(:call).with('https://example.com/actor/1') { featured_account } + allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(stubbed_service) + end + + it 'fetches the actor and creates the item' do + subject.call(collection_item) + + expect(stubbed_service).to have_received(:call) + expect(verification_request).to have_been_requested + + expect(collection_item.account_id).to eq featured_account.id + expect(collection_item).to be_accepted + end + end + end + + context 'when the authorization cannot be verified' do + let(:verification_request) do + stub_request(:get, 'https://example.com/auth/1') + .to_return(status: 404) + end + + it 'creates item without attached account and in proper state' do + subject.call(collection_item) + + expect(collection_item.account_id).to be_nil + expect(collection_item).to be_rejected + end + end +end diff --git a/spec/workers/activitypub/verify_featured_item_worker_spec.rb b/spec/workers/activitypub/verify_featured_item_worker_spec.rb new file mode 100644 index 0000000000..f94313ce1d --- /dev/null +++ b/spec/workers/activitypub/verify_featured_item_worker_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::VerifyFeaturedItemWorker do + let(:worker) { described_class.new } + let(:service) { instance_double(ActivityPub::VerifyFeaturedItemService, call: true) } + + describe '#perform' do + let(:collection_item) { Fabricate(:unverified_remote_collection_item) } + + before { stub_service } + + it 'sends the status to the service' do + worker.perform(collection_item.id) + + expect(service).to have_received(:call).with(collection_item) + end + + it 'returns nil for non-existent record' do + result = worker.perform(123_123_123) + + expect(result).to be_nil + end + end + + def stub_service + allow(ActivityPub::VerifyFeaturedItemService) + .to receive(:new) + .and_return(service) + end +end