Add initial support for ingesting and verifying remote quote posts (#34370)
This commit is contained in:
		@@ -13,6 +13,7 @@
 | 
				
			|||||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
 | 
					- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
 | 
				
			||||||
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
 | 
					- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
 | 
				
			||||||
- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
 | 
					- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
 | 
				
			||||||
 | 
					- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## ActivityPub in Mastodon
 | 
					## ActivityPub in Mastodon
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,9 +45,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			|||||||
    @unresolved_mentions  = []
 | 
					    @unresolved_mentions  = []
 | 
				
			||||||
    @silenced_account_ids = []
 | 
					    @silenced_account_ids = []
 | 
				
			||||||
    @params               = {}
 | 
					    @params               = {}
 | 
				
			||||||
 | 
					    @quote                = nil
 | 
				
			||||||
 | 
					    @quote_uri            = nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    process_status_params
 | 
					    process_status_params
 | 
				
			||||||
    process_tags
 | 
					    process_tags
 | 
				
			||||||
 | 
					    process_quote
 | 
				
			||||||
    process_audience
 | 
					    process_audience
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ApplicationRecord.transaction do
 | 
					    ApplicationRecord.transaction do
 | 
				
			||||||
@@ -55,6 +58,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			|||||||
      attach_tags(@status)
 | 
					      attach_tags(@status)
 | 
				
			||||||
      attach_mentions(@status)
 | 
					      attach_mentions(@status)
 | 
				
			||||||
      attach_counts(@status)
 | 
					      attach_counts(@status)
 | 
				
			||||||
 | 
					      attach_quote(@status)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    resolve_thread(@status)
 | 
					    resolve_thread(@status)
 | 
				
			||||||
@@ -189,6 +193,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def attach_quote(status)
 | 
				
			||||||
 | 
					    return if @quote.nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @quote.status = status
 | 
				
			||||||
 | 
					    @quote.save
 | 
				
			||||||
 | 
					    ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, request_id: @options[:request_id])
 | 
				
			||||||
 | 
					  rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
 | 
				
			||||||
 | 
					    ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def process_tags
 | 
					  def process_tags
 | 
				
			||||||
    return if @object['tag'].nil?
 | 
					    return if @object['tag'].nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -203,6 +217,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def process_quote
 | 
				
			||||||
 | 
					    return unless Mastodon::Feature.inbound_quotes_enabled?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @quote_uri = @status_parser.quote_uri
 | 
				
			||||||
 | 
					    return if @quote_uri.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    approval_uri = @status_parser.quote_approval_uri
 | 
				
			||||||
 | 
					    approval_uri = nil if unsupported_uri_scheme?(approval_uri)
 | 
				
			||||||
 | 
					    @quote = Quote.new(account: @account, approval_uri: approval_uri)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def process_hashtag(tag)
 | 
					  def process_hashtag(tag)
 | 
				
			||||||
    return if tag['name'].blank?
 | 
					    return if tag['name'].blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,7 +5,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 | 
				
			|||||||
    if @account.uri == object_uri
 | 
					    if @account.uri == object_uri
 | 
				
			||||||
      delete_person
 | 
					      delete_person
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      delete_note
 | 
					      delete_object
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -17,7 +17,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def delete_note
 | 
					  def delete_object
 | 
				
			||||||
    return if object_uri.nil?
 | 
					    return if object_uri.nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
 | 
					    with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
 | 
				
			||||||
@@ -32,21 +32,38 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 | 
				
			|||||||
        Tombstone.find_or_create_by(uri: object_uri, account: @account)
 | 
					        Tombstone.find_or_create_by(uri: object_uri, account: @account)
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      @status   = Status.find_by(uri: object_uri, account: @account)
 | 
					      case @object['type']
 | 
				
			||||||
      @status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
 | 
					      when 'QuoteAuthorization'
 | 
				
			||||||
 | 
					        revoke_quote
 | 
				
			||||||
      return if @status.nil?
 | 
					      when 'Note', 'Question'
 | 
				
			||||||
 | 
					        delete_status
 | 
				
			||||||
      forwarder.forward! if forwarder.forwardable?
 | 
					      else
 | 
				
			||||||
      delete_now!
 | 
					        delete_status || revoke_quote
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def delete_status
 | 
				
			||||||
 | 
					    @status   = Status.find_by(uri: object_uri, account: @account)
 | 
				
			||||||
 | 
					    @status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return if @status.nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    forwarder.forward! if forwarder.forwardable?
 | 
				
			||||||
 | 
					    RemoveStatusService.new.call(@status, redraft: false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    true
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def revoke_quote
 | 
				
			||||||
 | 
					    @quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
 | 
				
			||||||
 | 
					    return if @quote.nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
 | 
				
			||||||
 | 
					    @quote.reject!
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def forwarder
 | 
					  def forwarder
 | 
				
			||||||
    @forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
 | 
					    @forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					 | 
				
			||||||
  def delete_now!
 | 
					 | 
				
			||||||
    RemoveStatusService.new.call(@status, redraft: false)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -101,6 +101,16 @@ class ActivityPub::Parser::StatusParser
 | 
				
			|||||||
    @object.dig(:shares, :totalItems)
 | 
					    @object.dig(:shares, :totalItems)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def quote_uri
 | 
				
			||||||
 | 
					    %w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key|
 | 
				
			||||||
 | 
					      value_or_id(as_array(@object[key]).first)
 | 
				
			||||||
 | 
					    end.first
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def quote_approval_uri
 | 
				
			||||||
 | 
					    as_array(@object['quoteAuthorization']).first
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def raw_language_code
 | 
					  def raw_language_code
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -71,6 +71,23 @@ class StatusCacheHydrator
 | 
				
			|||||||
    payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id)
 | 
					    payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id)
 | 
				
			||||||
    payload[:pinned]     = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
 | 
					    payload[:pinned]     = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
 | 
				
			||||||
    payload[:filtered]   = mapped_applied_custom_filter(account_id, status)
 | 
					    payload[:filtered]   = mapped_applied_custom_filter(account_id, status)
 | 
				
			||||||
 | 
					    payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id) if payload[:quote]
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def hydrate_quote_payload(empty_payload, quote, account_id)
 | 
				
			||||||
 | 
					    # TODO: properly handle quotes, including visibility and access control
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    empty_payload.tap do |payload|
 | 
				
			||||||
 | 
					      # Nothing to do if we're in the shallow (depth limit) case
 | 
				
			||||||
 | 
					      next unless payload.key?(:quoted_status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      # TODO: handle hiding a rendered status or showing a non-rendered status according to visibility
 | 
				
			||||||
 | 
					      if quote&.quoted_status.nil?
 | 
				
			||||||
 | 
					        payload[:quoted_status] = nil
 | 
				
			||||||
 | 
					      elsif payload[:quoted_status].present?
 | 
				
			||||||
 | 
					        payload[:quoted_status] = StatusCacheHydrator.new(quote.quoted_status).hydrate(account_id)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def mapped_applied_custom_filter(account_id, status)
 | 
					  def mapped_applied_custom_filter(account_id, status)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,6 +25,7 @@ module Status::SnapshotConcern
 | 
				
			|||||||
      poll_options: preloadable_poll&.options&.dup,
 | 
					      poll_options: preloadable_poll&.options&.dup,
 | 
				
			||||||
      account_id: account_id || self.account_id,
 | 
					      account_id: account_id || self.account_id,
 | 
				
			||||||
      created_at: at_time || edited_at,
 | 
					      created_at: at_time || edited_at,
 | 
				
			||||||
 | 
					      quote_id: quote&.id,
 | 
				
			||||||
      rate_limit: rate_limit
 | 
					      rate_limit: rate_limit
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										67
									
								
								app/models/quote.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								app/models/quote.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# == Schema Information
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Table name: quotes
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  id                :bigint(8)        not null, primary key
 | 
				
			||||||
 | 
					#  activity_uri      :string
 | 
				
			||||||
 | 
					#  approval_uri      :string
 | 
				
			||||||
 | 
					#  state             :integer          default("pending"), not null
 | 
				
			||||||
 | 
					#  created_at        :datetime         not null
 | 
				
			||||||
 | 
					#  updated_at        :datetime         not null
 | 
				
			||||||
 | 
					#  account_id        :bigint(8)        not null
 | 
				
			||||||
 | 
					#  quoted_account_id :bigint(8)
 | 
				
			||||||
 | 
					#  quoted_status_id  :bigint(8)
 | 
				
			||||||
 | 
					#  status_id         :bigint(8)        not null
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					class Quote < ApplicationRecord
 | 
				
			||||||
 | 
					  BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
 | 
				
			||||||
 | 
					  REFRESH_DEADLINE = 6.hours
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  enum :state,
 | 
				
			||||||
 | 
					       { pending: 0, accepted: 1, rejected: 2, revoked: 3 },
 | 
				
			||||||
 | 
					       validate: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  belongs_to :status
 | 
				
			||||||
 | 
					  belongs_to :quoted_status, class_name: 'Status', optional: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  belongs_to :account
 | 
				
			||||||
 | 
					  belongs_to :quoted_account, class_name: 'Account', optional: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  before_validation :set_accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
 | 
				
			||||||
 | 
					  validate :validate_visibility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def accept!
 | 
				
			||||||
 | 
					    update!(state: :accepted)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def reject!
 | 
				
			||||||
 | 
					    if accepted?
 | 
				
			||||||
 | 
					      update!(state: :revoked)
 | 
				
			||||||
 | 
					    elsif !revoked?
 | 
				
			||||||
 | 
					      update!(state: :rejected)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def schedule_refresh_if_stale!
 | 
				
			||||||
 | 
					    return unless quoted_status_id.present? && approval_uri.present? && updated_at <= BACKGROUND_REFRESH_INTERVAL.ago
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ActivityPub::QuoteRefreshWorker.perform_in(rand(REFRESH_DEADLINE), id)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def set_accounts
 | 
				
			||||||
 | 
					    self.account = status.account
 | 
				
			||||||
 | 
					    self.quoted_account = quoted_status&.account
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def validate_visibility
 | 
				
			||||||
 | 
					    return if account_id == quoted_account_id || quoted_status.nil? || quoted_status.distributable?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    errors.add(:quoted_status_id, :visibility_mismatch)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -93,6 +93,7 @@ class Status < ApplicationRecord
 | 
				
			|||||||
  has_one :status_stat, inverse_of: :status, dependent: nil
 | 
					  has_one :status_stat, inverse_of: :status, dependent: nil
 | 
				
			||||||
  has_one :poll, inverse_of: :status, dependent: :destroy
 | 
					  has_one :poll, inverse_of: :status, dependent: :destroy
 | 
				
			||||||
  has_one :trend, class_name: 'StatusTrend', inverse_of: :status, dependent: nil
 | 
					  has_one :trend, class_name: 'StatusTrend', inverse_of: :status, dependent: nil
 | 
				
			||||||
 | 
					  has_one :quote, inverse_of: :status, dependent: :destroy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  validates :uri, uniqueness: true, presence: true, unless: :local?
 | 
					  validates :uri, uniqueness: true, presence: true, unless: :local?
 | 
				
			||||||
  validates :text, presence: true, unless: -> { with_media? || reblog? }
 | 
					  validates :text, presence: true, unless: -> { with_media? || reblog? }
 | 
				
			||||||
@@ -154,16 +155,18 @@ class Status < ApplicationRecord
 | 
				
			|||||||
                   :status_stat,
 | 
					                   :status_stat,
 | 
				
			||||||
                   :tags,
 | 
					                   :tags,
 | 
				
			||||||
                   :preloadable_poll,
 | 
					                   :preloadable_poll,
 | 
				
			||||||
 | 
					                   quote: { status: { account: [:account_stat, user: :role] } },
 | 
				
			||||||
                   preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
 | 
					                   preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
 | 
				
			||||||
                   account: [:account_stat, user: :role],
 | 
					                   account: [:account_stat, user: :role],
 | 
				
			||||||
                   active_mentions: :account,
 | 
					                   active_mentions: :account,
 | 
				
			||||||
                   reblog: [
 | 
					                   reblog: [
 | 
				
			||||||
                     :application,
 | 
					                     :application,
 | 
				
			||||||
                     :tags,
 | 
					 | 
				
			||||||
                     :media_attachments,
 | 
					                     :media_attachments,
 | 
				
			||||||
                     :conversation,
 | 
					                     :conversation,
 | 
				
			||||||
                     :status_stat,
 | 
					                     :status_stat,
 | 
				
			||||||
 | 
					                     :tags,
 | 
				
			||||||
                     :preloadable_poll,
 | 
					                     :preloadable_poll,
 | 
				
			||||||
 | 
					                     quote: { status: { account: [:account_stat, user: :role] } },
 | 
				
			||||||
                     preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
 | 
					                     preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
 | 
				
			||||||
                     account: [:account_stat, user: :role],
 | 
					                     account: [:account_stat, user: :role],
 | 
				
			||||||
                     active_mentions: :account,
 | 
					                     active_mentions: :account,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,7 @@
 | 
				
			|||||||
#  media_descriptions           :text             is an Array
 | 
					#  media_descriptions           :text             is an Array
 | 
				
			||||||
#  poll_options                 :string           is an Array
 | 
					#  poll_options                 :string           is an Array
 | 
				
			||||||
#  sensitive                    :boolean
 | 
					#  sensitive                    :boolean
 | 
				
			||||||
 | 
					#  quote_id                     :bigint(8)
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StatusEdit < ApplicationRecord
 | 
					class StatusEdit < ApplicationRecord
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,11 +16,11 @@ class StatusRelationshipsPresenter
 | 
				
			|||||||
      @filters_map    = {}
 | 
					      @filters_map    = {}
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      statuses            = statuses.compact
 | 
					      statuses            = statuses.compact
 | 
				
			||||||
      status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
 | 
					      status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.proper.quote&.quoted_status_id] }.uniq.compact
 | 
				
			||||||
      conversation_ids    = statuses.filter_map(&:conversation_id).uniq
 | 
					      conversation_ids    = statuses.flat_map { |s| [s.proper.conversation_id, s.proper.quote&.quoted_status&.conversation_id] }.uniq.compact
 | 
				
			||||||
      pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
 | 
					      pinnable_status_ids = statuses.flat_map { |s| [s.proper, s.proper.quote&.quoted_status] }.compact.filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      @filters_map     = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
 | 
					      @filters_map     = build_filters_map(statuses.flat_map { |s| [s, s.proper.quote&.quoted_status] }.compact.uniq, current_account_id).merge(options[:filters_map] || {})
 | 
				
			||||||
      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
 | 
					      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
 | 
				
			||||||
      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
 | 
					      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
 | 
				
			||||||
      @bookmarks_map   = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
 | 
					      @bookmarks_map   = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										25
									
								
								app/serializers/rest/base_quote_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/serializers/rest/base_quote_serializer.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class REST::BaseQuoteSerializer < ActiveModel::Serializer
 | 
				
			||||||
 | 
					  attributes :state
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def state
 | 
				
			||||||
 | 
					    return object.state unless object.accepted?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Extra states when a status is unavailable
 | 
				
			||||||
 | 
					    return 'deleted' if object.quoted_status.nil?
 | 
				
			||||||
 | 
					    return 'unauthorized' if status_filter.filtered?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    object.state
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def quoted_status
 | 
				
			||||||
 | 
					    object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def status_filter
 | 
				
			||||||
 | 
					    @status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships] || {})
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										5
									
								
								app/serializers/rest/quote_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/serializers/rest/quote_serializer.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class REST::QuoteSerializer < REST::BaseQuoteSerializer
 | 
				
			||||||
 | 
					  has_one :quoted_status, serializer: REST::ShallowStatusSerializer
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										9
									
								
								app/serializers/rest/shallow_quote_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/serializers/rest/shallow_quote_serializer.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class REST::ShallowQuoteSerializer < REST::BaseQuoteSerializer
 | 
				
			||||||
 | 
					  attribute :quoted_status_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def quoted_status_id
 | 
				
			||||||
 | 
					    quoted_status&.id&.to_s
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										9
									
								
								app/serializers/rest/shallow_status_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/serializers/rest/shallow_status_serializer.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class REST::ShallowStatusSerializer < REST::StatusSerializer
 | 
				
			||||||
 | 
					  has_one :quote, key: :quote, serializer: REST::ShallowQuoteSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # It looks like redefining one `has_one` requires redefining all inherited ones
 | 
				
			||||||
 | 
					  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
 | 
				
			||||||
 | 
					  has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -10,6 +10,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
 | 
				
			|||||||
  has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
 | 
					  has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
 | 
				
			||||||
  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
					  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  has_one :quote, serializer: REST::QuoteSerializer, if: -> { object.quote_id.present? }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attribute :poll, if: -> { object.poll_options.present? }
 | 
					  attribute :poll, if: -> { object.poll_options.present? }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def content
 | 
					  def content
 | 
				
			||||||
@@ -19,4 +21,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
 | 
				
			|||||||
  def poll
 | 
					  def poll
 | 
				
			||||||
    { options: object.poll_options.map { |title| { title: title } } }
 | 
					    { options: object.poll_options.map { |title| { title: title } } }
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def quote
 | 
				
			||||||
 | 
					    object.quote_id == status.quote&.id ? status.quote : Quote.new(state: :pending)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,6 +29,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
 | 
				
			|||||||
  has_many :tags
 | 
					  has_many :tags
 | 
				
			||||||
  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
					  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  has_one :quote, key: :quote, serializer: REST::QuoteSerializer
 | 
				
			||||||
  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
 | 
					  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
 | 
				
			||||||
  has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
 | 
					  has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 | 
				
			|||||||
    @account                   = status.account
 | 
					    @account                   = status.account
 | 
				
			||||||
    @media_attachments_changed = false
 | 
					    @media_attachments_changed = false
 | 
				
			||||||
    @poll_changed              = false
 | 
					    @poll_changed              = false
 | 
				
			||||||
 | 
					    @quote_changed             = false
 | 
				
			||||||
    @request_id                = request_id
 | 
					    @request_id                = request_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Only native types can be updated at the moment
 | 
					    # Only native types can be updated at the moment
 | 
				
			||||||
@@ -158,7 +159,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 | 
				
			|||||||
    @status.sensitive    = @account.sensitized? || @status_parser.sensitive || false
 | 
					    @status.sensitive    = @account.sensitized? || @status_parser.sensitive || false
 | 
				
			||||||
    @status.language     = @status_parser.language
 | 
					    @status.language     = @status_parser.language
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed
 | 
					    @significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed || @quote_changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @status.edited_at = @status_parser.edited_at if significant_changes?
 | 
					    @status.edited_at = @status_parser.edited_at if significant_changes?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -183,6 +184,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 | 
				
			|||||||
    update_tags!
 | 
					    update_tags!
 | 
				
			||||||
    update_mentions!
 | 
					    update_mentions!
 | 
				
			||||||
    update_emojis!
 | 
					    update_emojis!
 | 
				
			||||||
 | 
					    update_quote!
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def update_tags!
 | 
					  def update_tags!
 | 
				
			||||||
@@ -262,6 +264,45 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update_quote!
 | 
				
			||||||
 | 
					    return unless Mastodon::Feature.inbound_quotes_enabled?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    quote = nil
 | 
				
			||||||
 | 
					    quote_uri = @status_parser.quote_uri
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if quote_uri.present?
 | 
				
			||||||
 | 
					      approval_uri = @status_parser.quote_approval_uri
 | 
				
			||||||
 | 
					      approval_uri = nil if unsupported_uri_scheme?(approval_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if @status.quote.present?
 | 
				
			||||||
 | 
					        # If the quoted post has changed, discard the old object and create a new one
 | 
				
			||||||
 | 
					        if @status.quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(@status.quote.quoted_status) != quote_uri
 | 
				
			||||||
 | 
					          @status.quote.destroy
 | 
				
			||||||
 | 
					          quote = Quote.create(status: @status, approval_uri: approval_uri)
 | 
				
			||||||
 | 
					          @quote_changed = true
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
 | 
					          quote = @status.quote
 | 
				
			||||||
 | 
					          quote.update(approval_uri: approval_uri, state: :pending) if quote.approval_uri != @status_parser.quote_approval_uri
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      else
 | 
				
			||||||
 | 
					        quote = Quote.create(status: @status, approval_uri: approval_uri)
 | 
				
			||||||
 | 
					        @quote_changed = true
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if quote.present?
 | 
				
			||||||
 | 
					      begin
 | 
				
			||||||
 | 
					        quote.save
 | 
				
			||||||
 | 
					        ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, request_id: @request_id)
 | 
				
			||||||
 | 
					      rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
 | 
				
			||||||
 | 
					        ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    elsif @status.quote.present?
 | 
				
			||||||
 | 
					      @status.quote.destroy!
 | 
				
			||||||
 | 
					      @quote_changed = true
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def update_counts!
 | 
					  def update_counts!
 | 
				
			||||||
    likes = @status_parser.favourites_count
 | 
					    likes = @status_parser.favourites_count
 | 
				
			||||||
    shares =  @status_parser.reblogs_count
 | 
					    shares =  @status_parser.reblogs_count
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										112
									
								
								app/services/activitypub/verify_quote_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								app/services/activitypub/verify_quote_service.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,112 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ActivityPub::VerifyQuoteService < BaseService
 | 
				
			||||||
 | 
					  include JsonLdHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Optionally fetch quoted post, and verify the quote is authorized
 | 
				
			||||||
 | 
					  def call(quote, fetchable_quoted_uri: nil, prefetched_body: nil, request_id: nil)
 | 
				
			||||||
 | 
					    @request_id = request_id
 | 
				
			||||||
 | 
					    @quote = quote
 | 
				
			||||||
 | 
					    @fetching_error = nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    fetch_quoted_post_if_needed!(fetchable_quoted_uri)
 | 
				
			||||||
 | 
					    return if fast_track_approval! || quote.approval_uri.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @json = fetch_approval_object(quote.approval_uri, prefetched_body:)
 | 
				
			||||||
 | 
					    return quote.reject! if @json.nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
 | 
				
			||||||
 | 
					    return unless matching_type? && matching_quote_uri?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Opportunistically import embedded posts if needed
 | 
				
			||||||
 | 
					    return if import_quoted_post_if_needed!(fetchable_quoted_uri) && fast_track_approval!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Raise an error if we failed to fetch the status
 | 
				
			||||||
 | 
					    raise @fetching_error if @quote.status.nil? && @fetching_error
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return unless matching_quoted_post? && matching_quoted_author?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    quote.accept!
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # FEP-044f defines rules that don't require the approval flow
 | 
				
			||||||
 | 
					  def fast_track_approval!
 | 
				
			||||||
 | 
					    return false if @quote.quoted_status_id.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Always allow someone to quote themselves
 | 
				
			||||||
 | 
					    if @quote.account_id == @quote.quoted_account_id
 | 
				
			||||||
 | 
					      @quote.accept!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      true
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Always allow someone to quote posts in which they are mentioned
 | 
				
			||||||
 | 
					    if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id })
 | 
				
			||||||
 | 
					      @quote.accept!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      true
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def fetch_approval_object(uri, prefetched_body: nil)
 | 
				
			||||||
 | 
					    if prefetched_body.nil?
 | 
				
			||||||
 | 
					      fetch_resource(uri, true, @quote.account.followers.local.first, raise_on_error: :temporary)
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      body_to_json(prefetched_body, compare_id: uri)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def matching_type?
 | 
				
			||||||
 | 
					    supported_context?(@json) && equals_or_includes?(@json['type'], 'QuoteAuthorization')
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def matching_quote_uri?
 | 
				
			||||||
 | 
					    ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject'])
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def fetch_quoted_post_if_needed!(uri)
 | 
				
			||||||
 | 
					    return if uri.nil? || @quote.quoted_status.present?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
 | 
				
			||||||
 | 
					    status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @quote.update(quoted_status: status) if status.present?
 | 
				
			||||||
 | 
					  rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
 | 
				
			||||||
 | 
					    @fetching_error = e
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def import_quoted_post_if_needed!(uri)
 | 
				
			||||||
 | 
					    # No need to fetch if we already have a post
 | 
				
			||||||
 | 
					    return if uri.nil? || @quote.quoted_status_id.present? || !@json['interactionTarget'].is_a?(Hash)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # NOTE: Replacing the object's context by that of the parent activity is
 | 
				
			||||||
 | 
					    # not sound, but it's consistent with the rest of the codebase
 | 
				
			||||||
 | 
					    object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
 | 
				
			||||||
 | 
					    return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if status.present?
 | 
				
			||||||
 | 
					      @quote.update(quoted_status: status)
 | 
				
			||||||
 | 
					      true
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def matching_quoted_post?
 | 
				
			||||||
 | 
					    return false if @quote.quoted_status_id.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ActivityPub::TagManager.instance.uri_for(@quote.quoted_status) == value_or_id(@json['interactionTarget'])
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def matching_quoted_author?
 | 
				
			||||||
 | 
					    ActivityPub::TagManager.instance.uri_for(@quote.quoted_account) == value_or_id(@json['attributedTo'])
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										15
									
								
								app/workers/activitypub/quote_refresh_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/workers/activitypub/quote_refresh_worker.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ActivityPub::QuoteRefreshWorker
 | 
				
			||||||
 | 
					  include Sidekiq::Worker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def perform(quote_id)
 | 
				
			||||||
 | 
					    quote = Quote.find_by(id: quote_id)
 | 
				
			||||||
 | 
					    return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    quote.touch
 | 
				
			||||||
 | 
					    ActivityPub::VerifyQuoteService.new.call(quote)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										19
									
								
								app/workers/activitypub/refetch_and_verify_quote_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/workers/activitypub/refetch_and_verify_quote_worker.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ActivityPub::RefetchAndVerifyQuoteWorker
 | 
				
			||||||
 | 
					  include Sidekiq::Worker
 | 
				
			||||||
 | 
					  include ExponentialBackoff
 | 
				
			||||||
 | 
					  include JsonLdHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  sidekiq_options queue: 'pull', retry: 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def perform(quote_id, quoted_uri, options = {})
 | 
				
			||||||
 | 
					    quote = Quote.find(quote_id)
 | 
				
			||||||
 | 
					    ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id])
 | 
				
			||||||
 | 
					  rescue ActiveRecord::RecordNotFound
 | 
				
			||||||
 | 
					    # Do nothing
 | 
				
			||||||
 | 
					    true
 | 
				
			||||||
 | 
					  rescue Mastodon::UnexpectedResponseError => e
 | 
				
			||||||
 | 
					    raise e unless response_error_unsalvageable?(e.response)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										20
									
								
								db/migrate/20250411094808_create_quotes.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								db/migrate/20250411094808_create_quotes.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CreateQuotes < ActiveRecord::Migration[8.0]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    create_table :quotes do |t|
 | 
				
			||||||
 | 
					      t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false, null: false
 | 
				
			||||||
 | 
					      t.belongs_to :status, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
 | 
				
			||||||
 | 
					      t.belongs_to :quoted_status, foreign_key: { to_table: :statuses, on_delete: :nullify }, null: true
 | 
				
			||||||
 | 
					      t.belongs_to :quoted_account, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true
 | 
				
			||||||
 | 
					      t.integer :state, null: false, default: 0
 | 
				
			||||||
 | 
					      t.string :approval_uri, index: { where: 'approval_uri IS NOT NULL' }
 | 
				
			||||||
 | 
					      t.string :activity_uri, index: { unique: true, where: 'activity_uri IS NOT NULL' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      t.timestamps
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Can be used in the future to e.g. bulk-reject quotes from blocked accounts
 | 
				
			||||||
 | 
					    add_index :quotes, [:account_id, :quoted_account_id]
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										7
									
								
								db/migrate/20250411095859_add_quote_id_to_status_edit.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								db/migrate/20250411095859_add_quote_id_to_status_edit.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AddQuoteIdToStatusEdit < ActiveRecord::Migration[8.0]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    add_column :status_edits, :quote_id, :bigint, null: true
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										25
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								db/schema.rb
									
									
									
									
									
								
							@@ -10,7 +10,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
# It's strongly recommended that you check this file into your version control system.
 | 
					# It's strongly recommended that you check this file into your version control system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
 | 
					ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do
 | 
				
			||||||
  # These are extensions that must be enabled in order to support this database
 | 
					  # These are extensions that must be enabled in order to support this database
 | 
				
			||||||
  enable_extension "pg_catalog.plpgsql"
 | 
					  enable_extension "pg_catalog.plpgsql"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -871,6 +871,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
 | 
				
			|||||||
    t.string "url"
 | 
					    t.string "url"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  create_table "quotes", force: :cascade do |t|
 | 
				
			||||||
 | 
					    t.bigint "account_id", null: false
 | 
				
			||||||
 | 
					    t.bigint "status_id", null: false
 | 
				
			||||||
 | 
					    t.bigint "quoted_status_id"
 | 
				
			||||||
 | 
					    t.bigint "quoted_account_id"
 | 
				
			||||||
 | 
					    t.integer "state", default: 0, null: false
 | 
				
			||||||
 | 
					    t.string "approval_uri"
 | 
				
			||||||
 | 
					    t.string "activity_uri"
 | 
				
			||||||
 | 
					    t.datetime "created_at", null: false
 | 
				
			||||||
 | 
					    t.datetime "updated_at", null: false
 | 
				
			||||||
 | 
					    t.index ["account_id", "quoted_account_id"], name: "index_quotes_on_account_id_and_quoted_account_id"
 | 
				
			||||||
 | 
					    t.index ["activity_uri"], name: "index_quotes_on_activity_uri", unique: true, where: "(activity_uri IS NOT NULL)"
 | 
				
			||||||
 | 
					    t.index ["approval_uri"], name: "index_quotes_on_approval_uri", where: "(approval_uri IS NOT NULL)"
 | 
				
			||||||
 | 
					    t.index ["quoted_account_id"], name: "index_quotes_on_quoted_account_id"
 | 
				
			||||||
 | 
					    t.index ["quoted_status_id"], name: "index_quotes_on_quoted_status_id"
 | 
				
			||||||
 | 
					    t.index ["status_id"], name: "index_quotes_on_status_id", unique: true
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create_table "relationship_severance_events", force: :cascade do |t|
 | 
					  create_table "relationship_severance_events", force: :cascade do |t|
 | 
				
			||||||
    t.integer "type", null: false
 | 
					    t.integer "type", null: false
 | 
				
			||||||
    t.string "target_name", null: false
 | 
					    t.string "target_name", null: false
 | 
				
			||||||
@@ -1007,6 +1025,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
 | 
				
			|||||||
    t.text "media_descriptions", array: true
 | 
					    t.text "media_descriptions", array: true
 | 
				
			||||||
    t.string "poll_options", array: true
 | 
					    t.string "poll_options", array: true
 | 
				
			||||||
    t.boolean "sensitive"
 | 
					    t.boolean "sensitive"
 | 
				
			||||||
 | 
					    t.bigint "quote_id"
 | 
				
			||||||
    t.index ["account_id"], name: "index_status_edits_on_account_id"
 | 
					    t.index ["account_id"], name: "index_status_edits_on_account_id"
 | 
				
			||||||
    t.index ["status_id"], name: "index_status_edits_on_status_id"
 | 
					    t.index ["status_id"], name: "index_status_edits_on_status_id"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -1350,6 +1369,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
 | 
				
			|||||||
  add_foreign_key "polls", "statuses", on_delete: :cascade
 | 
					  add_foreign_key "polls", "statuses", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
 | 
					  add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify
 | 
					  add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify
 | 
				
			||||||
 | 
					  add_foreign_key "quotes", "accounts", column: "quoted_account_id", on_delete: :nullify
 | 
				
			||||||
 | 
					  add_foreign_key "quotes", "accounts", on_delete: :cascade
 | 
				
			||||||
 | 
					  add_foreign_key "quotes", "statuses", column: "quoted_status_id", on_delete: :nullify
 | 
				
			||||||
 | 
					  add_foreign_key "quotes", "statuses", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "report_notes", "accounts", on_delete: :cascade
 | 
					  add_foreign_key "report_notes", "accounts", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "report_notes", "reports", on_delete: :cascade
 | 
					  add_foreign_key "report_notes", "reports", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
 | 
					  add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								spec/fabricators/quote_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								spec/fabricators/quote_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Fabricator(:quote) do
 | 
				
			||||||
 | 
					  status { Fabricate.build(:status) }
 | 
				
			||||||
 | 
					  quoted_status { Fabricate.build(:status) }
 | 
				
			||||||
 | 
					  state :pending
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -7,7 +7,15 @@ RSpec.describe ActivityPub::Activity::Create do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  let(:json) do
 | 
					  let(:json) do
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      '@context': 'https://www.w3.org/ns/activitystreams',
 | 
					      '@context': [
 | 
				
			||||||
 | 
					        'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          quote: {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
      id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
 | 
					      id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
 | 
				
			||||||
      type: 'Create',
 | 
					      type: 'Create',
 | 
				
			||||||
      actor: ActivityPub::TagManager.instance.uri_for(sender),
 | 
					      actor: ActivityPub::TagManager.instance.uri_for(sender),
 | 
				
			||||||
@@ -879,6 +887,115 @@ RSpec.describe ActivityPub::Activity::Create do
 | 
				
			|||||||
        end
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      context 'with an unverifiable quote of a known post', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					        let(:quoted_status) { Fabricate(:status) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let(:object_json) do
 | 
				
			||||||
 | 
					          build_object(
 | 
				
			||||||
 | 
					            type: 'Note',
 | 
				
			||||||
 | 
					            content: 'woah what she said is amazing',
 | 
				
			||||||
 | 
					            quote: ActivityPub::TagManager.instance.uri_for(quoted_status)
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        it 'creates a status with an unverified quote' do
 | 
				
			||||||
 | 
					          expect { subject.perform }.to change(sender.statuses, :count).by(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          status = sender.statuses.first
 | 
				
			||||||
 | 
					          expect(status).to_not be_nil
 | 
				
			||||||
 | 
					          expect(status.quote).to_not be_nil
 | 
				
			||||||
 | 
					          expect(status.quote).to have_attributes(
 | 
				
			||||||
 | 
					            state: 'pending',
 | 
				
			||||||
 | 
					            approval_uri: nil
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      context 'with an unverifiable unknown post', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					        let(:unknown_post_uri) { 'https://unavailable.example.com/unavailable-post' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let(:object_json) do
 | 
				
			||||||
 | 
					          build_object(
 | 
				
			||||||
 | 
					            type: 'Note',
 | 
				
			||||||
 | 
					            content: 'woah what she said is amazing',
 | 
				
			||||||
 | 
					            quote: unknown_post_uri
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        before do
 | 
				
			||||||
 | 
					          stub_request(:get, unknown_post_uri).to_return(status: 404)
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        it 'creates a status with an unverified quote' do
 | 
				
			||||||
 | 
					          expect { subject.perform }.to change(sender.statuses, :count).by(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          status = sender.statuses.first
 | 
				
			||||||
 | 
					          expect(status).to_not be_nil
 | 
				
			||||||
 | 
					          expect(status.quote).to_not be_nil
 | 
				
			||||||
 | 
					          expect(status.quote).to have_attributes(
 | 
				
			||||||
 | 
					            state: 'pending',
 | 
				
			||||||
 | 
					            approval_uri: nil
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      context 'with a verifiable quote of a known post', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					        let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					        let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					        let(:approval_uri) { 'https://quoted.example.com/quote-approval' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let(:object_json) do
 | 
				
			||||||
 | 
					          build_object(
 | 
				
			||||||
 | 
					            type: 'Note',
 | 
				
			||||||
 | 
					            content: 'woah what she said is amazing',
 | 
				
			||||||
 | 
					            quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					            quoteAuthorization: approval_uri
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        before do
 | 
				
			||||||
 | 
					          stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
 | 
				
			||||||
 | 
					            '@context': [
 | 
				
			||||||
 | 
					              'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                toot: 'http://joinmastodon.org/ns#',
 | 
				
			||||||
 | 
					                QuoteAuthorization: 'toot:QuoteAuthorization',
 | 
				
			||||||
 | 
					                gts: 'https://gotosocial.org/ns#',
 | 
				
			||||||
 | 
					                interactionPolicy: {
 | 
				
			||||||
 | 
					                  '@id': 'gts:interactionPolicy',
 | 
				
			||||||
 | 
					                  '@type': '@id',
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                interactingObject: {
 | 
				
			||||||
 | 
					                  '@id': 'gts:interactingObject',
 | 
				
			||||||
 | 
					                  '@type': '@id',
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                interactionTarget: {
 | 
				
			||||||
 | 
					                  '@id': 'gts:interactionTarget',
 | 
				
			||||||
 | 
					                  '@type': '@id',
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            type: 'QuoteAuthorization',
 | 
				
			||||||
 | 
					            id: approval_uri,
 | 
				
			||||||
 | 
					            attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
 | 
				
			||||||
 | 
					            interactingObject: object_json[:id],
 | 
				
			||||||
 | 
					            interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					          }))
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        it 'creates a status with a verified quote' do
 | 
				
			||||||
 | 
					          expect { subject.perform }.to change(sender.statuses, :count).by(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          status = sender.statuses.first
 | 
				
			||||||
 | 
					          expect(status).to_not be_nil
 | 
				
			||||||
 | 
					          expect(status.quote).to_not be_nil
 | 
				
			||||||
 | 
					          expect(status.quote).to have_attributes(
 | 
				
			||||||
 | 
					            state: 'accepted',
 | 
				
			||||||
 | 
					            approval_uri: approval_uri
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      context 'when a vote to a local poll' do
 | 
					      context 'when a vote to a local poll' do
 | 
				
			||||||
        let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
 | 
					        let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
 | 
				
			||||||
        let!(:local_status) { Fabricate(:status, poll: poll) }
 | 
					        let!(:local_status) { Fabricate(:status, poll: poll) }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -77,4 +77,61 @@ RSpec.describe ActivityPub::Activity::Delete do
 | 
				
			|||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the deleted object is an account' do
 | 
				
			||||||
 | 
					    let(:json) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Delete',
 | 
				
			||||||
 | 
					        actor: ActivityPub::TagManager.instance.uri_for(sender),
 | 
				
			||||||
 | 
					        object: ActivityPub::TagManager.instance.uri_for(sender),
 | 
				
			||||||
 | 
					        signature: 'foo',
 | 
				
			||||||
 | 
					      }.with_indifferent_access
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    describe '#perform' do
 | 
				
			||||||
 | 
					      subject { described_class.new(json, sender) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let(:service) { instance_double(DeleteAccountService, call: true) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        allow(DeleteAccountService).to receive(:new).and_return(service)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'calls the account deletion service' do
 | 
				
			||||||
 | 
					        subject.perform
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(service)
 | 
				
			||||||
 | 
					          .to have_received(:call).with(sender, { reserve_username: false, skip_activitypub: true })
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the deleted object is a quote authorization' do
 | 
				
			||||||
 | 
					    let(:quoter) { Fabricate(:account, domain: 'b.example.com') }
 | 
				
			||||||
 | 
					    let(:status) { Fabricate(:status, account: quoter) }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: sender, uri: 'https://example.com/statuses/1234') }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, approval_uri: 'https://example.com/approvals/1234', state: :accepted, status: status, quoted_status: quoted_status) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:json) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Delete',
 | 
				
			||||||
 | 
					        actor: ActivityPub::TagManager.instance.uri_for(sender),
 | 
				
			||||||
 | 
					        object: quote.approval_uri,
 | 
				
			||||||
 | 
					        signature: 'foo',
 | 
				
			||||||
 | 
					      }.with_indifferent_access
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    describe '#perform' do
 | 
				
			||||||
 | 
					      subject { described_class.new(json, sender) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'revokes the authorization' do
 | 
				
			||||||
 | 
					        expect { subject.perform }
 | 
				
			||||||
 | 
					          .to change { quote.reload.state }.to('revoked')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -40,10 +40,119 @@ RSpec.describe StatusCacheHydrator do
 | 
				
			|||||||
        end
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      context 'when handling an unapproved quote' do
 | 
				
			||||||
 | 
					        let(:quoted_status) { Fabricate(:status) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        before do
 | 
				
			||||||
 | 
					          Fabricate(:quote, status: status, quoted_status: quoted_status, state: :pending)
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					          expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					          expect(subject[:quote]).to_not be_nil
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      context 'when handling an approved quote' do
 | 
				
			||||||
 | 
					        let(:quoted_status) { Fabricate(:status) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        before do
 | 
				
			||||||
 | 
					          Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted)
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					          expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					          expect(subject[:quote]).to_not be_nil
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        context 'when the quoted post has been favourited' do
 | 
				
			||||||
 | 
					          before do
 | 
				
			||||||
 | 
					            FavouriteService.new.call(account, quoted_status)
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					            expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					            expect(subject[:quote]).to_not be_nil
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        context 'when the quoted post has been reblogged' do
 | 
				
			||||||
 | 
					          before do
 | 
				
			||||||
 | 
					            ReblogService.new.call(account, quoted_status)
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					            expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					            expect(subject[:quote]).to_not be_nil
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        context 'when the quoted post matches account filters' do
 | 
				
			||||||
 | 
					          let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          before do
 | 
				
			||||||
 | 
					            account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          it 'renders the same attributes as a full render' do
 | 
				
			||||||
 | 
					            expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					            expect(subject[:quote]).to_not be_nil
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      context 'when handling a reblog' do
 | 
					      context 'when handling a reblog' do
 | 
				
			||||||
        let(:reblog) { Fabricate(:status) }
 | 
					        let(:reblog) { Fabricate(:status) }
 | 
				
			||||||
        let(:status) { Fabricate(:status, reblog: reblog) }
 | 
					        let(:status) { Fabricate(:status, reblog: reblog) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        context 'when the reblog has an approved quote' do
 | 
				
			||||||
 | 
					          let(:quoted_status) { Fabricate(:status) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          before do
 | 
				
			||||||
 | 
					            Fabricate(:quote, status: reblog, quoted_status: quoted_status, state: :accepted)
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					            expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					            expect(subject[:reblog][:quote]).to_not be_nil
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          context 'when the quoted post has been favourited' do
 | 
				
			||||||
 | 
					            before do
 | 
				
			||||||
 | 
					              FavouriteService.new.call(account, quoted_status)
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					              expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					              expect(subject[:reblog][:quote]).to_not be_nil
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          context 'when the quoted post has been reblogged' do
 | 
				
			||||||
 | 
					            before do
 | 
				
			||||||
 | 
					              ReblogService.new.call(account, quoted_status)
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            it 'renders the same attributes as full render' do
 | 
				
			||||||
 | 
					              expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					              expect(subject[:reblog][:quote]).to_not be_nil
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          context 'when the quoted post matches account filters' do
 | 
				
			||||||
 | 
					            let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            before do
 | 
				
			||||||
 | 
					              account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            it 'renders the same attributes as a full render' do
 | 
				
			||||||
 | 
					              expect(subject).to eql(compare_to_hash)
 | 
				
			||||||
 | 
					              expect(subject[:reblog][:quote]).to_not be_nil
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        context 'when it has been favourited' do
 | 
					        context 'when it has been favourited' do
 | 
				
			||||||
          before do
 | 
					          before do
 | 
				
			||||||
            FavouriteService.new.call(account, reblog)
 | 
					            FavouriteService.new.call(account, reblog)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										87
									
								
								spec/serializers/rest/quote_serializer_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								spec/serializers/rest/quote_serializer_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe REST::QuoteSerializer do
 | 
				
			||||||
 | 
					  subject do
 | 
				
			||||||
 | 
					    serialized_record_json(
 | 
				
			||||||
 | 
					      quote,
 | 
				
			||||||
 | 
					      described_class,
 | 
				
			||||||
 | 
					      options: {
 | 
				
			||||||
 | 
					        scope: current_user,
 | 
				
			||||||
 | 
					        scope_name: :current_user,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:current_user) { Fabricate(:user) }
 | 
				
			||||||
 | 
					  let(:quote) { Fabricate(:quote) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with a pending quote' do
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status: nil,
 | 
				
			||||||
 | 
					          state: 'pending'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an accepted quote' do
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status: be_a(Hash),
 | 
				
			||||||
 | 
					          state: 'accepted'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an accepted quote of a deleted post' do
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      quote.quoted_status.destroy!
 | 
				
			||||||
 | 
					      quote.reload
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status: nil,
 | 
				
			||||||
 | 
					          state: 'deleted'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an accepted quote of a blocked user' do
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      quote.quoted_account.block!(current_user.account)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status: nil,
 | 
				
			||||||
 | 
					          state: 'unauthorized'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with a recursive accepted quote' do
 | 
				
			||||||
 | 
					    let(:status) { Fabricate(:status) }
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status: be_a(Hash),
 | 
				
			||||||
 | 
					          state: 'accepted'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										93
									
								
								spec/serializers/rest/shallow_quote_serializer_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								spec/serializers/rest/shallow_quote_serializer_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe REST::ShallowQuoteSerializer do
 | 
				
			||||||
 | 
					  subject do
 | 
				
			||||||
 | 
					    serialized_record_json(
 | 
				
			||||||
 | 
					      quote,
 | 
				
			||||||
 | 
					      described_class,
 | 
				
			||||||
 | 
					      options: {
 | 
				
			||||||
 | 
					        scope: current_user,
 | 
				
			||||||
 | 
					        scope_name: :current_user,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:current_user) { Fabricate(:user) }
 | 
				
			||||||
 | 
					  let(:quote) { Fabricate(:quote) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with a pending quote' do
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status_id: nil,
 | 
				
			||||||
 | 
					          state: 'pending'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to_not have_key(:quoted_status)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an accepted quote' do
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status_id: be_a(String),
 | 
				
			||||||
 | 
					          state: 'accepted'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to_not have_key(:quoted_status)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an accepted quote of a deleted post' do
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      quote.quoted_status.destroy!
 | 
				
			||||||
 | 
					      quote.reload
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status_id: nil,
 | 
				
			||||||
 | 
					          state: 'deleted'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an accepted quote of a blocked user' do
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      quote.quoted_account.block!(current_user.account)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status_id: nil,
 | 
				
			||||||
 | 
					          state: 'unauthorized'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with a recursive accepted quote' do
 | 
				
			||||||
 | 
					    let(:status) { Fabricate(:status) }
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns expected values' do
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to include(
 | 
				
			||||||
 | 
					          quoted_status_id: be_a(String),
 | 
				
			||||||
 | 
					          state: 'accepted'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      expect(subject.deep_symbolize_keys)
 | 
				
			||||||
 | 
					        .to_not have_key(:quoted_status)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -5,7 +5,7 @@ require 'rails_helper'
 | 
				
			|||||||
RSpec.describe ActivityPub::ProcessStatusUpdateService do
 | 
					RSpec.describe ActivityPub::ProcessStatusUpdateService do
 | 
				
			||||||
  subject { described_class.new }
 | 
					  subject { described_class.new }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) }
 | 
					  let!(:status) { Fabricate(:status, text: 'Hello world', uri: 'https://example.com/statuses/1234', account: Fabricate(:account, domain: 'example.com')) }
 | 
				
			||||||
  let(:bogus_mention) { 'https://example.com/users/erroringuser' }
 | 
					  let(:bogus_mention) { 'https://example.com/users/erroringuser' }
 | 
				
			||||||
  let(:payload) do
 | 
					  let(:payload) do
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -435,6 +435,398 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status has an existing unverified quote and adds an approval link', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: nil) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quoteAuthorization',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					        quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					        quoteAuthorization: approval_uri,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            toot: 'http://joinmastodon.org/ns#',
 | 
				
			||||||
 | 
					            QuoteAuthorization: 'toot:QuoteAuthorization',
 | 
				
			||||||
 | 
					            gts: 'https://gotosocial.org/ns#',
 | 
				
			||||||
 | 
					            interactionPolicy: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionPolicy',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactingObject: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactingObject',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactionTarget: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionTarget',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        type: 'QuoteAuthorization',
 | 
				
			||||||
 | 
					        id: approval_uri,
 | 
				
			||||||
 | 
					        attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
 | 
				
			||||||
 | 
					        interactingObject: ActivityPub::TagManager.instance.uri_for(status),
 | 
				
			||||||
 | 
					        interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					      }))
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates the approval URI and verifies the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change(quote, :approval_uri).to(approval_uri)
 | 
				
			||||||
 | 
					        .and change(quote, :state).to('accepted')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status has an existing verified quote and removes an approval link', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quoteAuthorization',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					        quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'removes the approval URI and unverifies the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change(quote, :approval_uri).to(nil)
 | 
				
			||||||
 | 
					        .and change(quote, :state).to('pending')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status adds a verifiable quote', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quoteAuthorization',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					        quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					        quoteAuthorization: approval_uri,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            toot: 'http://joinmastodon.org/ns#',
 | 
				
			||||||
 | 
					            QuoteAuthorization: 'toot:QuoteAuthorization',
 | 
				
			||||||
 | 
					            gts: 'https://gotosocial.org/ns#',
 | 
				
			||||||
 | 
					            interactionPolicy: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionPolicy',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactingObject: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactingObject',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactionTarget: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionTarget',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        type: 'QuoteAuthorization',
 | 
				
			||||||
 | 
					        id: approval_uri,
 | 
				
			||||||
 | 
					        attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
 | 
				
			||||||
 | 
					        interactingObject: ActivityPub::TagManager.instance.uri_for(status),
 | 
				
			||||||
 | 
					        interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					      }))
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates the approval URI and verifies the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change(status, :quote).from(nil)
 | 
				
			||||||
 | 
					      expect(status.quote.approval_uri).to eq approval_uri
 | 
				
			||||||
 | 
					      expect(status.quote.state).to eq 'accepted'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status adds a unverifiable quote', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quoteAuthorization',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					        quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates the approval URI but does not verify the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change(status, :quote).from(nil)
 | 
				
			||||||
 | 
					      expect(status.quote.approval_uri).to be_nil
 | 
				
			||||||
 | 
					      expect(status.quote.state).to eq 'pending'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status removes a verified quote', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'removes the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change { status.reload.quote }.to(nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status removes an unverified quote', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: nil, state: :pending) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'removes the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change { status.reload.quote }.to(nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status swaps a verified quote with an unverifiable quote', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let(:second_quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quoteAuthorization',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					        quote: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
 | 
				
			||||||
 | 
					        quoteAuthorization: approval_uri,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            toot: 'http://joinmastodon.org/ns#',
 | 
				
			||||||
 | 
					            QuoteAuthorization: 'toot:QuoteAuthorization',
 | 
				
			||||||
 | 
					            gts: 'https://gotosocial.org/ns#',
 | 
				
			||||||
 | 
					            interactionPolicy: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionPolicy',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactingObject: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactingObject',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactionTarget: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionTarget',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        type: 'QuoteAuthorization',
 | 
				
			||||||
 | 
					        id: approval_uri,
 | 
				
			||||||
 | 
					        attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
 | 
				
			||||||
 | 
					        interactingObject: ActivityPub::TagManager.instance.uri_for(status),
 | 
				
			||||||
 | 
					        interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
 | 
				
			||||||
 | 
					      }))
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates the URI and unverifies the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change { status.quote.quoted_status }.from(quoted_status).to(second_quoted_status)
 | 
				
			||||||
 | 
					        .and change { status.quote.state }.from('accepted')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when the status swaps a verified quote with another verifiable quote', feature: :inbound_quotes do
 | 
				
			||||||
 | 
					    let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:second_quoted_account) { Fabricate(:account, domain: 'second-quoted.example.com') }
 | 
				
			||||||
 | 
					    let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					    let(:second_quoted_status) { Fabricate(:status, account: second_quoted_account) }
 | 
				
			||||||
 | 
					    let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
 | 
				
			||||||
 | 
					    let(:second_approval_uri) { 'https://second-quoted.example.com/approvals/2' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:payload) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quote',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            '@id': 'https://w3id.org/fep/044f#quoteAuthorization',
 | 
				
			||||||
 | 
					            '@type': '@id',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        id: 'foo',
 | 
				
			||||||
 | 
					        type: 'Note',
 | 
				
			||||||
 | 
					        summary: 'Show more',
 | 
				
			||||||
 | 
					        content: 'Hello universe',
 | 
				
			||||||
 | 
					        updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					        quote: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
 | 
				
			||||||
 | 
					        quoteAuthorization: second_approval_uri,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      stub_request(:get, second_approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            toot: 'http://joinmastodon.org/ns#',
 | 
				
			||||||
 | 
					            QuoteAuthorization: 'toot:QuoteAuthorization',
 | 
				
			||||||
 | 
					            gts: 'https://gotosocial.org/ns#',
 | 
				
			||||||
 | 
					            interactionPolicy: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionPolicy',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactingObject: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactingObject',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactionTarget: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionTarget',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        type: 'QuoteAuthorization',
 | 
				
			||||||
 | 
					        id: second_approval_uri,
 | 
				
			||||||
 | 
					        attributedTo: ActivityPub::TagManager.instance.uri_for(second_quoted_status.account),
 | 
				
			||||||
 | 
					        interactingObject: ActivityPub::TagManager.instance.uri_for(status),
 | 
				
			||||||
 | 
					        interactionTarget: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
 | 
				
			||||||
 | 
					      }))
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates the URI and unverifies the quote' do
 | 
				
			||||||
 | 
					      expect { subject.call(status, json, json) }
 | 
				
			||||||
 | 
					        .to change { status.quote.quoted_status }.from(quoted_status).to(second_quoted_status)
 | 
				
			||||||
 | 
					        .and change { status.quote.approval_uri }.from(approval_uri).to(second_approval_uri)
 | 
				
			||||||
 | 
					        .and(not_change { status.quote.state })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def poll_option_json(name, votes)
 | 
					  def poll_option_json(name, votes)
 | 
				
			||||||
    { type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } }
 | 
					    { type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } }
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										246
									
								
								spec/services/activitypub/verify_quote_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								spec/services/activitypub/verify_quote_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,246 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe ActivityPub::VerifyQuoteService do
 | 
				
			||||||
 | 
					  subject { described_class.new }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:account) { Fabricate(:account, domain: 'a.example.com') }
 | 
				
			||||||
 | 
					  let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') }
 | 
				
			||||||
 | 
					  let(:quoted_status) { Fabricate(:status, account: quoted_account) }
 | 
				
			||||||
 | 
					  let(:status) { Fabricate(:status, account: account) }
 | 
				
			||||||
 | 
					  let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an unfetchable approval URI' do
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://b.example.com/approvals/1234' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      stub_request(:get, approval_uri)
 | 
				
			||||||
 | 
					        .to_return(status: 404)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an already-fetched post' do
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('rejected')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an already-verified quote' do
 | 
				
			||||||
 | 
					      let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'rejects the quote' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('revoked')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with an approval URI' do
 | 
				
			||||||
 | 
					    let(:approval_uri) { 'https://b.example.com/approvals/1234' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:approval_type) { 'QuoteAuthorization' }
 | 
				
			||||||
 | 
					    let(:approval_id) { approval_uri }
 | 
				
			||||||
 | 
					    let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) }
 | 
				
			||||||
 | 
					    let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) }
 | 
				
			||||||
 | 
					    let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:json) do
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        '@context': [
 | 
				
			||||||
 | 
					          'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            toot: 'http://joinmastodon.org/ns#',
 | 
				
			||||||
 | 
					            QuoteAuthorization: 'toot:QuoteAuthorization',
 | 
				
			||||||
 | 
					            gts: 'https://gotosocial.org/ns#',
 | 
				
			||||||
 | 
					            interactionPolicy: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionPolicy',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactingObject: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactingObject',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            interactionTarget: {
 | 
				
			||||||
 | 
					              '@id': 'gts:interactionTarget',
 | 
				
			||||||
 | 
					              '@type': '@id',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        type: approval_type,
 | 
				
			||||||
 | 
					        id: approval_id,
 | 
				
			||||||
 | 
					        attributedTo: approval_attributed_to,
 | 
				
			||||||
 | 
					        interactingObject: approval_interacting_object,
 | 
				
			||||||
 | 
					        interactionTarget: approval_interaction_target,
 | 
				
			||||||
 | 
					      }.with_indifferent_access
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      stub_request(:get, approval_uri)
 | 
				
			||||||
 | 
					        .to_return(status: 200, body: Oj.dump(json), headers: { 'Content-Type': 'application/activity+json' })
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with a valid activity for already-fetched posts' do
 | 
				
			||||||
 | 
					      it 'updates the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('accepted')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(a_request(:get, approval_uri))
 | 
				
			||||||
 | 
					          .to have_been_made.once
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with a valid activity for a post that cannot be fetched but is inlined' do
 | 
				
			||||||
 | 
					      let(:quoted_status) { nil }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let(:approval_interaction_target) do
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          type: 'Note',
 | 
				
			||||||
 | 
					          id: 'https://b.example.com/unknown-quoted',
 | 
				
			||||||
 | 
					          to: 'https://www.w3.org/ns/activitystreams#Public',
 | 
				
			||||||
 | 
					          attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
 | 
				
			||||||
 | 
					          content: 'previously unknown post',
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        stub_request(:get, 'https://b.example.com/unknown-quoted')
 | 
				
			||||||
 | 
					          .to_return(status: 404)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted') }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('accepted')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(a_request(:get, approval_uri))
 | 
				
			||||||
 | 
					          .to have_been_made.once
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with a valid activity for a post that cannot be fetched and is inlined from an untrusted source' do
 | 
				
			||||||
 | 
					      let(:quoted_status) { nil }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let(:approval_interaction_target) do
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          type: 'Note',
 | 
				
			||||||
 | 
					          id: 'https://example.com/unknown-quoted',
 | 
				
			||||||
 | 
					          to: 'https://www.w3.org/ns/activitystreams#Public',
 | 
				
			||||||
 | 
					          attributedTo: ActivityPub::TagManager.instance.uri_for(account),
 | 
				
			||||||
 | 
					          content: 'previously unknown post',
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        stub_request(:get, 'https://example.com/unknown-quoted')
 | 
				
			||||||
 | 
					          .to_return(status: 404)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote, fetchable_quoted_uri: 'https://example.com/unknown-quoted') }
 | 
				
			||||||
 | 
					          .to not_change(quote, :state)
 | 
				
			||||||
 | 
					          .and not_change(quote, :quoted_status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(a_request(:get, approval_uri))
 | 
				
			||||||
 | 
					          .to have_been_made.once
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
 | 
				
			||||||
 | 
					      it 'updates the status without fetching the activity' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote, prefetched_body: Oj.dump(json)) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('accepted')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(a_request(:get, approval_uri))
 | 
				
			||||||
 | 
					          .to_not have_been_made
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an unverifiable approval' do
 | 
				
			||||||
 | 
					      let(:approval_uri) { 'https://evil.com/approvals/1234' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to_not change(quote, :state)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an invalid approval document because of a mismatched ID' do
 | 
				
			||||||
 | 
					      let(:approval_id) { 'https://evil.com/approvals/1234' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not accept the quote' do
 | 
				
			||||||
 | 
					        # NOTE: maybe we want to skip that instead of rejecting it?
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('rejected')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an approval from the wrong account' do
 | 
				
			||||||
 | 
					      let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to_not change(quote, :state)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an approval for the wrong quoted post' do
 | 
				
			||||||
 | 
					      let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to_not change(quote, :state)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an approval for the wrong quote post' do
 | 
				
			||||||
 | 
					      let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to_not change(quote, :state)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'with an approval of the wrong type' do
 | 
				
			||||||
 | 
					      let(:approval_type) { 'ReplyAuthorization' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to_not change(quote, :state)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'with fast-track authorizations' do
 | 
				
			||||||
 | 
					    let(:approval_uri) { nil }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'without any fast-track condition' do
 | 
				
			||||||
 | 
					      it 'does not update the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to_not change(quote, :state)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'when the account and the quoted account are the same' do
 | 
				
			||||||
 | 
					      let(:quoted_account) { account }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('accepted')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'when the account is mentioned by the quoted post' do
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        quoted_status.mentions << Mention.new(account: account)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates the status' do
 | 
				
			||||||
 | 
					        expect { subject.call(quote) }
 | 
				
			||||||
 | 
					          .to change(quote, :state).to('accepted')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										44
									
								
								spec/workers/activitypub/quote_refresh_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								spec/workers/activitypub/quote_refresh_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe ActivityPub::QuoteRefreshWorker do
 | 
				
			||||||
 | 
					  let(:worker) { described_class.new }
 | 
				
			||||||
 | 
					  let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe '#perform' do
 | 
				
			||||||
 | 
					    before { stub_service }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
				
			||||||
 | 
					    let(:status)  { Fabricate(:status, account: account) }
 | 
				
			||||||
 | 
					    let(:quote) { Fabricate(:quote, status: status, quoted_status: nil, updated_at: updated_at) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'when dealing with an old quote' do
 | 
				
			||||||
 | 
					      let(:updated_at) { (Quote::BACKGROUND_REFRESH_INTERVAL * 2).ago }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'sends the status to the service and bumps the updated date' do
 | 
				
			||||||
 | 
					        expect { worker.perform(quote.id) }
 | 
				
			||||||
 | 
					          .to(change { quote.reload.updated_at })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(service).to have_received(:call).with(quote)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'when dealing with a recent quote' do
 | 
				
			||||||
 | 
					      let(:updated_at) { Time.now.utc }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not call the service and does not touch the quote' do
 | 
				
			||||||
 | 
					        expect { worker.perform(quote.id) }
 | 
				
			||||||
 | 
					          .to_not(change { quote.reload.updated_at })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(service).to_not have_received(:call).with(quote)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def stub_service
 | 
				
			||||||
 | 
					    allow(ActivityPub::VerifyQuoteService)
 | 
				
			||||||
 | 
					      .to receive(:new)
 | 
				
			||||||
 | 
					      .and_return(service)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe ActivityPub::RefetchAndVerifyQuoteWorker do
 | 
				
			||||||
 | 
					  let(:worker) { described_class.new }
 | 
				
			||||||
 | 
					  let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe '#perform' do
 | 
				
			||||||
 | 
					    before { stub_service }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
				
			||||||
 | 
					    let(:status)  { Fabricate(:status, account: account) }
 | 
				
			||||||
 | 
					    let(:quote)   { Fabricate(:quote, status: status, quoted_status: nil) }
 | 
				
			||||||
 | 
					    let(:url) { 'https://example.com/quoted-status' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'sends the status to the service' do
 | 
				
			||||||
 | 
					      worker.perform(quote.id, url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(service).to have_received(:call).with(quote, fetchable_quoted_uri: url, request_id: anything)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns nil for non-existent record' do
 | 
				
			||||||
 | 
					      result = worker.perform(123_123_123, url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(result).to be(true)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def stub_service
 | 
				
			||||||
 | 
					    allow(ActivityPub::VerifyQuoteService)
 | 
				
			||||||
 | 
					      .to receive(:new)
 | 
				
			||||||
 | 
					      .and_return(service)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
		Reference in New Issue
	
	Block a user