Compare commits
6 Commits
main
...
features/q
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d15ba8311 | ||
|
|
009b877023 | ||
|
|
ee49cbd492 | ||
|
|
223d7ced76 | ||
|
|
1a47c4f265 | ||
|
|
9ff21c72f5 |
@ -45,6 +45,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
@unresolved_mentions = []
|
||||
@silenced_account_ids = []
|
||||
@params = {}
|
||||
@quote = nil
|
||||
@quote_uri = nil
|
||||
|
||||
process_status_params
|
||||
process_tags
|
||||
@ -55,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
attach_tags(@status)
|
||||
attach_mentions(@status)
|
||||
attach_counts(@status)
|
||||
attach_quote(@status)
|
||||
end
|
||||
|
||||
resolve_thread(@status)
|
||||
@ -189,6 +192,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def attach_quote(status)
|
||||
return if @quote.nil?
|
||||
|
||||
@quote.status = status
|
||||
ActivityPub::VerifyQuoteService.new.call(@quote, expected_quoted_uri: @quote_uri)
|
||||
@quote.save
|
||||
end
|
||||
|
||||
def process_tags
|
||||
return if @object['tag'].nil?
|
||||
|
||||
@ -199,6 +210,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
process_mention tag
|
||||
elsif equals_or_includes?(tag['type'], 'Emoji')
|
||||
process_emoji tag
|
||||
elsif equals_or_includes?(tag['type'], 'Link')
|
||||
process_link tag
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -246,6 +259,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def process_link(tag)
|
||||
return if tag['href'].blank? || @quote.present?
|
||||
|
||||
if tag['mediaType'] == 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' && tag['rel'] == 'https://misskey-hub.net/ns#_misskey_quote'
|
||||
@quote = Quote.new(account: @account, approval_uri: tag['approvedBy'])
|
||||
@quote_uri = tag['href']
|
||||
|
||||
# TODO: error handling
|
||||
@quote.status = ActivityPub::TagManager.instance.uri_to_resource(tag['href'], Status)
|
||||
@quote.status ||= ActivityPub::FetchRemoteStatusService.new.call(tag['href'], on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
||||
end
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return [] if @object['attachment'].nil?
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||
if @account.uri == object_uri
|
||||
delete_person
|
||||
else
|
||||
delete_note
|
||||
delete_object
|
||||
end
|
||||
end
|
||||
|
||||
@ -17,7 +17,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def delete_note
|
||||
def delete_object
|
||||
return if object_uri.nil?
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
@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?
|
||||
delete_now!
|
||||
case @object['type']
|
||||
when 'QuoteAuthorization'
|
||||
revoke_quote
|
||||
when 'Note', 'Question'
|
||||
delete_status
|
||||
else
|
||||
delete_status || revoke_quote
|
||||
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
|
||||
@forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
|
||||
end
|
||||
|
||||
def delete_now!
|
||||
RemoveStatusService.new.call(@status, redraft: false)
|
||||
end
|
||||
end
|
||||
|
||||
@ -43,6 +43,9 @@ class Notification < ApplicationRecord
|
||||
reblog: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
quote: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
follow: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
@ -80,6 +83,7 @@ class Notification < ApplicationRecord
|
||||
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
||||
status: :status,
|
||||
reblog: [status: :reblog],
|
||||
quote: [status: :quoted_status],
|
||||
mention: [mention: :status],
|
||||
favourite: [favourite: :status],
|
||||
poll: [poll: :status],
|
||||
@ -118,6 +122,8 @@ class Notification < ApplicationRecord
|
||||
status
|
||||
when :reblog
|
||||
status&.reblog
|
||||
when :quote
|
||||
status&.quoted_status
|
||||
when :favourite
|
||||
favourite&.status
|
||||
when :mention
|
||||
|
||||
51
app/models/quote.rb
Normal file
51
app/models/quote.rb
Normal file
@ -0,0 +1,51 @@
|
||||
# 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)
|
||||
# quoted_account_id :bigint(8)
|
||||
# quoted_status_id :bigint(8)
|
||||
# status_id :bigint(8) not null
|
||||
#
|
||||
class Quote < ApplicationRecord
|
||||
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? }
|
||||
|
||||
def accept!
|
||||
update(state: :accepted)
|
||||
end
|
||||
|
||||
def reject!
|
||||
if accepted?
|
||||
update(state: :revoked)
|
||||
elsif !revoked?
|
||||
update(state: :rejected)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_accounts!
|
||||
self.account = status.account
|
||||
self.quoted_account = quoted_status&.account
|
||||
end
|
||||
end
|
||||
@ -44,6 +44,13 @@ class Status < ApplicationRecord
|
||||
|
||||
MEDIA_ATTACHMENTS_LIMIT = 4
|
||||
|
||||
QUOTE_APPROVAL_POLICY_FLAGS = {
|
||||
public: (1 << 0),
|
||||
followers: (1 << 1),
|
||||
followed: (1 << 2),
|
||||
unknown: (1 << 3),
|
||||
}.freeze
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
||||
self.discard_column = :deleted_at
|
||||
@ -93,6 +100,7 @@ class Status < ApplicationRecord
|
||||
has_one :status_stat, inverse_of: :status, dependent: nil
|
||||
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||
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 :text, presence: true, unless: -> { with_media? || reblog? }
|
||||
@ -154,6 +162,7 @@ class Status < ApplicationRecord
|
||||
:status_stat,
|
||||
:tags,
|
||||
:preloadable_poll,
|
||||
quote: { status: { account: [:account_stat, user: :role] } },
|
||||
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
|
||||
account: [:account_stat, user: :role],
|
||||
active_mentions: :account,
|
||||
@ -164,6 +173,7 @@ class Status < ApplicationRecord
|
||||
:conversation,
|
||||
:status_stat,
|
||||
:preloadable_poll,
|
||||
:quote,
|
||||
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
|
||||
account: [:account_stat, user: :role],
|
||||
active_mentions: :account,
|
||||
|
||||
@ -21,7 +21,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
||||
end
|
||||
|
||||
def status_type?
|
||||
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
|
||||
[:favourite, :reblog, :quote, :status, :mention, :poll, :update].include?(object.type)
|
||||
end
|
||||
|
||||
def report_type?
|
||||
|
||||
7
app/serializers/rest/quote_serializer.rb
Normal file
7
app/serializers/rest/quote_serializer.rb
Normal file
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::QuoteSerializer < ActiveModel::Serializer
|
||||
attributes :state
|
||||
|
||||
has_one :quoted_status, serializer: REST::StatusSerializer
|
||||
end
|
||||
@ -29,6 +29,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
has_many :tags
|
||||
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 :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
|
||||
|
||||
91
app/services/activitypub/verify_quote_service.rb
Normal file
91
app/services/activitypub/verify_quote_service.rb
Normal file
@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::VerifyQuoteService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(quote, expected_quoted_uri: nil, prefetched_body: nil)
|
||||
@quote = quote
|
||||
return if fast_track_approval! || quote.approval_uri.blank?
|
||||
|
||||
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
|
||||
if @json.nil?
|
||||
# It is possible the proof isn't dereferenceable yet, so only reject if already accepted
|
||||
quote.reject! unless @quote.pending?
|
||||
return
|
||||
end
|
||||
|
||||
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!(expected_quoted_uri) && fast_track_approval!
|
||||
|
||||
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, 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 import_quoted_post_if_needed!(uri)
|
||||
return if uri.nil? || @quote.quoted_status_id.present?
|
||||
return if value_or_id(@json['interactionTarget']) == uri
|
||||
|
||||
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||
# TODO: import embedded post
|
||||
|
||||
if status.present?
|
||||
quote.update(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
|
||||
19
db/migrate/20250404094808_create_quotes.rb
Normal file
19
db/migrate/20250404094808_create_quotes.rb
Normal file
@ -0,0 +1,19 @@
|
||||
# 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
|
||||
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
|
||||
|
||||
add_index :quotes, [:account_id, :quoted_account_id]
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddQuoteApprovalPolicyToStatuses < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :statuses, :quote_approval_policy, :integer
|
||||
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.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_04_04_095029) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@ -884,6 +884,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
t.string "url"
|
||||
end
|
||||
|
||||
create_table "quotes", force: :cascade do |t|
|
||||
t.bigint "account_id"
|
||||
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|
|
||||
t.integer "type", null: false
|
||||
t.string "target_name", null: false
|
||||
@ -1080,6 +1098,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
t.boolean "trendable"
|
||||
t.bigint "ordered_media_attachment_ids", array: true
|
||||
t.datetime "fetched_replies_at"
|
||||
t.integer "quote_approval_policy"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||
t.index ["account_id"], name: "index_statuses_on_account_id"
|
||||
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
|
||||
@ -1364,6 +1383,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
add_foreign_key "polls", "statuses", 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 "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", "reports", on_delete: :cascade
|
||||
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
|
||||
@ -77,4 +77,61 @@ RSpec.describe ActivityPub::Activity::Delete do
|
||||
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
|
||||
|
||||
27
spec/serializers/rest/quote_serializer_spec.rb
Normal file
27
spec/serializers/rest/quote_serializer_spec.rb
Normal file
@ -0,0 +1,27 @@
|
||||
# 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) }
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: be_a(Hash),
|
||||
state: 'pending'
|
||||
)
|
||||
end
|
||||
end
|
||||
169
spec/services/activitypub/verify_quote_service_spec.rb
Normal file
169
spec/services/activitypub/verify_quote_service_spec.rb
Normal file
@ -0,0 +1,169 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
# TODO: high-level tests
|
||||
# - fetch unapproved post with a posteriori approval
|
||||
# - fetch implicitly approved post
|
||||
# - handle explicit revocation (`Delete`)
|
||||
# - handle post getting unapproved (`Update`)
|
||||
|
||||
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 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 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 a mismatched ID' do
|
||||
let(:approval_id) { '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 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
|
||||
Loading…
Reference in New Issue
Block a user