Add initial support for ingesting and verifying remote quote posts (#34370)

This commit is contained in:
Claire 2025-04-17 09:45:23 +02:00 committed by GitHub
parent a324edabdf
commit df2611a10f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1643 additions and 22 deletions

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:quote) do
status { Fabricate.build(:status) }
quoted_status { Fabricate.build(:status) }
state :pending
end

View file

@ -7,7 +7,15 @@ RSpec.describe ActivityPub::Activity::Create 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,
type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender),
@ -879,6 +887,115 @@ RSpec.describe ActivityPub::Activity::Create do
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
let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
let!(:local_status) { Fabricate(:status, poll: poll) }

View file

@ -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

View file

@ -40,10 +40,119 @@ RSpec.describe StatusCacheHydrator do
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
let(:reblog) { Fabricate(:status) }
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
before do
FavouriteService.new.call(account, reblog)

View 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

View 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

View file

@ -5,7 +5,7 @@ require 'rails_helper'
RSpec.describe ActivityPub::ProcessStatusUpdateService do
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(:payload) do
{
@ -435,6 +435,398 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
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)
{ type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } }
end

View 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

View 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

View file

@ -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