mirror of https://framagit.org/tykayn/mastodon
Add support for reversible suspensions through ActivityPub (#14989)
parent
ee8cf246cf
commit
3134691948
|
@ -102,6 +102,10 @@ class AccountsController < ApplicationController
|
|||
params[:username]
|
||||
end
|
||||
|
||||
def skip_temporary_suspension_response?
|
||||
request.format == :json
|
||||
end
|
||||
|
||||
def rss_url
|
||||
if tag_requested?
|
||||
short_account_tag_url(@account, params[:tag], format: 'rss')
|
||||
|
|
|
@ -8,4 +8,8 @@ class ActivityPub::BaseController < Api::BaseController
|
|||
def set_cache_headers
|
||||
response.headers['Vary'] = 'Signature' if authorized_fetch_mode?
|
||||
end
|
||||
|
||||
def skip_temporary_suspension_response?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,6 +33,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
|||
params[:account_username].present?
|
||||
end
|
||||
|
||||
def skip_temporary_suspension_response?
|
||||
true
|
||||
end
|
||||
|
||||
def body
|
||||
return @body if defined?(@body)
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||
end
|
||||
|
||||
def set_replies
|
||||
@replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses
|
||||
@replies = only_other_accounts? ? Status.where.not(account_id: @account.id).joins(:account).merge(Account.without_suspended) : @account.statuses
|
||||
@replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted])
|
||||
@replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
|
||||
end
|
||||
|
|
|
@ -29,6 +29,24 @@ module AccountOwnedConcern
|
|||
end
|
||||
|
||||
def check_account_suspension
|
||||
expires_in(3.minutes, public: true) && gone if @account.suspended?
|
||||
if @account.suspended_permanently?
|
||||
permanent_suspension_response
|
||||
elsif @account.suspended? && !skip_temporary_suspension_response?
|
||||
temporary_suspension_response
|
||||
end
|
||||
end
|
||||
|
||||
def skip_temporary_suspension_response?
|
||||
false
|
||||
end
|
||||
|
||||
def permanent_suspension_response
|
||||
expires_in(3.minutes, public: true)
|
||||
gone
|
||||
end
|
||||
|
||||
def temporary_suspension_response
|
||||
expires_in(3.minutes, public: true)
|
||||
forbidden
|
||||
end
|
||||
end
|
||||
|
|
|
@ -52,6 +52,14 @@ class FollowerAccountsController < ApplicationController
|
|||
account_followers_url(@account, page: page) unless page.nil?
|
||||
end
|
||||
|
||||
def next_page_url
|
||||
page_url(follows.next_page) if follows.respond_to?(:next_page)
|
||||
end
|
||||
|
||||
def prev_page_url
|
||||
page_url(follows.prev_page) if follows.respond_to?(:prev_page)
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
if page_requested?
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
|
@ -60,8 +68,8 @@ class FollowerAccountsController < ApplicationController
|
|||
size: @account.followers_count,
|
||||
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
|
||||
part_of: account_followers_url(@account),
|
||||
next: page_url(follows.next_page),
|
||||
prev: page_url(follows.prev_page)
|
||||
next: next_page_url,
|
||||
prev: prev_page_url
|
||||
)
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
|
|
|
@ -52,6 +52,14 @@ class FollowingAccountsController < ApplicationController
|
|||
account_following_index_url(@account, page: page) unless page.nil?
|
||||
end
|
||||
|
||||
def next_page_url
|
||||
page_url(follows.next_page) if follows.respond_to?(:next_page)
|
||||
end
|
||||
|
||||
def prev_page_url
|
||||
page_url(follows.prev_page) if follows.respond_to?(:prev_page)
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
if page_requested?
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
|
@ -60,8 +68,8 @@ class FollowingAccountsController < ApplicationController
|
|||
size: @account.following_count,
|
||||
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
|
||||
part_of: account_following_index_url(@account),
|
||||
next: page_url(follows.next_page),
|
||||
prev: page_url(follows.prev_page)
|
||||
next: next_page_url,
|
||||
prev: prev_page_url
|
||||
)
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
|
|
|
@ -42,7 +42,7 @@ class Settings::DeletesController < Settings::BaseController
|
|||
end
|
||||
|
||||
def destroy_account!
|
||||
current_account.suspend!
|
||||
current_account.suspend!(origin: :local)
|
||||
AccountDeletionWorker.perform_async(current_user.account_id)
|
||||
sign_out
|
||||
end
|
||||
|
|
|
@ -35,7 +35,7 @@ module WellKnown
|
|||
end
|
||||
|
||||
def check_account_suspension
|
||||
expires_in(3.minutes, public: true) && gone if @account.suspended?
|
||||
expires_in(3.minutes, public: true) && gone if @account.suspended_permanently?
|
||||
end
|
||||
|
||||
def bad_request
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
margin-bottom: 10px;
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
|
|
@ -23,6 +23,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
|||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
}.freeze
|
||||
|
||||
def self.default_key_transform
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
class Webfinger
|
||||
class Error < StandardError; end
|
||||
class GoneError < Error; end
|
||||
class RedirectError < StandardError; end
|
||||
|
||||
class Response
|
||||
def initialize(body)
|
||||
|
@ -47,6 +49,8 @@ class Webfinger
|
|||
res.body_with_limit
|
||||
elsif res.code == 404 && use_fallback
|
||||
body_from_host_meta
|
||||
elsif res.code == 410
|
||||
raise Webfinger::GoneError, "#{@uri} is gone from the server"
|
||||
else
|
||||
raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
|
||||
end
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
# header_storage_schema_version :integer
|
||||
# devices_url :string
|
||||
# sensitized_at :datetime
|
||||
# suspension_origin :integer
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
|
@ -73,6 +74,7 @@ class Account < ApplicationRecord
|
|||
}.freeze
|
||||
|
||||
enum protocol: [:ostatus, :activitypub]
|
||||
enum suspension_origin: [:local, :remote], _prefix: true
|
||||
|
||||
validates :username, presence: true
|
||||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||
|
@ -222,17 +224,25 @@ class Account < ApplicationRecord
|
|||
suspended_at.present?
|
||||
end
|
||||
|
||||
def suspend!(date = Time.now.utc)
|
||||
def suspended_permanently?
|
||||
suspended? && deletion_request.nil?
|
||||
end
|
||||
|
||||
def suspended_temporarily?
|
||||
suspended? && deletion_request.present?
|
||||
end
|
||||
|
||||
def suspend!(date: Time.now.utc, origin: :local)
|
||||
transaction do
|
||||
create_deletion_request!
|
||||
update!(suspended_at: date)
|
||||
update!(suspended_at: date, suspension_origin: origin)
|
||||
end
|
||||
end
|
||||
|
||||
def unsuspend!
|
||||
transaction do
|
||||
deletion_request&.destroy!
|
||||
update!(suspended_at: nil)
|
||||
update!(suspended_at: nil, suspension_origin: nil)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -127,7 +127,7 @@ class Admin::AccountAction
|
|||
def handle_suspend!
|
||||
authorize(target_account, :suspend?)
|
||||
log_action(:suspend, target_account)
|
||||
target_account.suspend!
|
||||
target_account.suspend!(origin: :local)
|
||||
end
|
||||
|
||||
def text_for_warning
|
||||
|
|
|
@ -18,11 +18,11 @@ class AccountPolicy < ApplicationPolicy
|
|||
end
|
||||
|
||||
def destroy?
|
||||
record.suspended? && record.deletion_request.present? && admin?
|
||||
record.suspended_temporarily? && admin?
|
||||
end
|
||||
|
||||
def unsuspend?
|
||||
staff?
|
||||
staff? && record.suspension_origin_local?
|
||||
end
|
||||
|
||||
def sensitive?
|
||||
|
|
|
@ -7,7 +7,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
|
||||
context_extensions :manually_approves_followers, :featured, :also_known_as,
|
||||
:moved_to, :property_value, :identity_proof,
|
||||
:discoverable, :olm
|
||||
:discoverable, :olm, :suspended
|
||||
|
||||
attributes :id, :type, :following, :followers,
|
||||
:inbox, :outbox, :featured, :featured_tags,
|
||||
|
@ -23,6 +23,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
attribute :devices, unless: :instance_actor?
|
||||
attribute :moved_to, if: :moved?
|
||||
attribute :also_known_as, if: :also_known_as?
|
||||
attribute :suspended, if: :suspended?
|
||||
|
||||
class EndpointsSerializer < ActivityPub::Serializer
|
||||
include RoutingHelper
|
||||
|
@ -39,7 +40,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
has_one :icon, serializer: ActivityPub::ImageSerializer, if: :avatar_exists?
|
||||
has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists?
|
||||
|
||||
delegate :moved?, :instance_actor?, to: :object
|
||||
delegate :suspended?, :instance_actor?, to: :object
|
||||
|
||||
def id
|
||||
object.instance_actor? ? instance_actor_url : account_url(object)
|
||||
|
@ -93,12 +94,16 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
object.username
|
||||
end
|
||||
|
||||
def discoverable
|
||||
object.suspended? ? false : (object.discoverable || false)
|
||||
end
|
||||
|
||||
def name
|
||||
object.display_name
|
||||
object.suspended? ? '' : object.display_name
|
||||
end
|
||||
|
||||
def summary
|
||||
Formatter.instance.simplified_format(object)
|
||||
object.suspended? ? '' : Formatter.instance.simplified_format(object)
|
||||
end
|
||||
|
||||
def icon
|
||||
|
@ -113,36 +118,44 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
object
|
||||
end
|
||||
|
||||
def suspended
|
||||
object.suspended?
|
||||
end
|
||||
|
||||
def url
|
||||
object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object)
|
||||
end
|
||||
|
||||
def avatar_exists?
|
||||
object.avatar?
|
||||
!object.suspended? && object.avatar?
|
||||
end
|
||||
|
||||
def header_exists?
|
||||
object.header?
|
||||
!object.suspended? && object.header?
|
||||
end
|
||||
|
||||
def manually_approves_followers
|
||||
object.locked
|
||||
object.suspended? ? false : object.locked
|
||||
end
|
||||
|
||||
def virtual_tags
|
||||
object.emojis + object.tags
|
||||
object.suspended? ? [] : (object.emojis + object.tags)
|
||||
end
|
||||
|
||||
def virtual_attachments
|
||||
object.fields + object.identity_proofs.active
|
||||
object.suspended? ? [] : (object.fields + object.identity_proofs.active)
|
||||
end
|
||||
|
||||
def moved_to
|
||||
ActivityPub::TagManager.instance.uri_for(object.moved_to_account)
|
||||
end
|
||||
|
||||
def moved?
|
||||
!object.suspended? && object.moved?
|
||||
end
|
||||
|
||||
def also_known_as?
|
||||
!object.also_known_as.empty?
|
||||
!object.suspended? && !object.also_known_as.empty?
|
||||
end
|
||||
|
||||
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
|
||||
|
|
|
@ -18,10 +18,11 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
|
||||
@account ||= Account.find_remote(@username, @domain)
|
||||
@old_public_key = @account&.public_key
|
||||
@old_protocol = @account&.protocol
|
||||
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
|
||||
@account ||= Account.find_remote(@username, @domain)
|
||||
@old_public_key = @account&.public_key
|
||||
@old_protocol = @account&.protocol
|
||||
@suspension_changed = false
|
||||
|
||||
create_account if @account.nil?
|
||||
update_account
|
||||
|
@ -37,8 +38,9 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
after_protocol_change! if protocol_changed?
|
||||
after_key_change! if key_changed? && !@options[:signed_with_known_key]
|
||||
clear_tombstones! if key_changed?
|
||||
after_suspension_change! if suspension_changed?
|
||||
|
||||
unless @options[:only_key]
|
||||
unless @options[:only_key] || @account.suspended?
|
||||
check_featured_collection! if @account.featured_collection_url.present?
|
||||
check_links! unless @account.fields.empty?
|
||||
end
|
||||
|
@ -52,20 +54,23 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
|
||||
def create_account
|
||||
@account = Account.new
|
||||
@account.protocol = :activitypub
|
||||
@account.username = @username
|
||||
@account.domain = @domain
|
||||
@account.private_key = nil
|
||||
@account.suspended_at = domain_block.created_at if auto_suspend?
|
||||
@account.silenced_at = domain_block.created_at if auto_silence?
|
||||
@account.protocol = :activitypub
|
||||
@account.username = @username
|
||||
@account.domain = @domain
|
||||
@account.private_key = nil
|
||||
@account.suspended_at = domain_block.created_at if auto_suspend?
|
||||
@account.suspension_origin = :local if auto_suspend?
|
||||
@account.silenced_at = domain_block.created_at if auto_silence?
|
||||
@account.save
|
||||
end
|
||||
|
||||
def update_account
|
||||
@account.last_webfingered_at = Time.now.utc unless @options[:only_key]
|
||||
@account.protocol = :activitypub
|
||||
|
||||
set_immediate_attributes!
|
||||
set_fetchable_attributes! unless @options[:only_keys]
|
||||
set_suspension!
|
||||
set_immediate_attributes! unless @account.suspended?
|
||||
set_fetchable_attributes! unless @options[:only_keys] || @account.suspended?
|
||||
|
||||
@account.save_with_optional_media!
|
||||
end
|
||||
|
@ -99,6 +104,18 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
@account.moved_to_account = @json['movedTo'].present? ? moved_account : nil
|
||||
end
|
||||
|
||||
def set_suspension!
|
||||
return if @account.suspended? && @account.suspension_origin_local?
|
||||
|
||||
if @account.suspended? && !@json['suspended']
|
||||
@account.unsuspend!
|
||||
@suspension_changed = true
|
||||
elsif !@account.suspended? && @json['suspended']
|
||||
@account.suspend!(origin: :remote)
|
||||
@suspension_changed = true
|
||||
end
|
||||
end
|
||||
|
||||
def after_protocol_change!
|
||||
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
|
||||
end
|
||||
|
@ -107,6 +124,14 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
RefollowWorker.perform_async(@account.id)
|
||||
end
|
||||
|
||||
def after_suspension_change!
|
||||
if @account.suspended?
|
||||
Admin::SuspensionWorker.perform_async(@account.id)
|
||||
else
|
||||
Admin::UnsuspensionWorker.perform_async(@account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def check_featured_collection!
|
||||
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
|
||||
end
|
||||
|
@ -227,6 +252,10 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
!@old_public_key.nil? && @old_public_key != @account.public_key
|
||||
end
|
||||
|
||||
def suspension_changed?
|
||||
@suspension_changed
|
||||
end
|
||||
|
||||
def clear_tombstones!
|
||||
Tombstone.where(account_id: @account.id).delete_all
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ class ActivityPub::ProcessCollectionService < BaseService
|
|||
@json = Oj.load(body, mode: :strict)
|
||||
@options = options
|
||||
|
||||
return if !supported_context? || (different_actor? && verify_account!.nil?) || @account.suspended? || @account.local?
|
||||
return if !supported_context? || (different_actor? && verify_account!.nil?) || suspended_actor? || @account.local?
|
||||
|
||||
case @json['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
|
@ -28,6 +28,14 @@ class ActivityPub::ProcessCollectionService < BaseService
|
|||
@json['actor'].present? && value_or_id(@json['actor']) != @account.uri
|
||||
end
|
||||
|
||||
def suspended_actor?
|
||||
@account.suspended? && !activity_allowed_while_suspended?
|
||||
end
|
||||
|
||||
def activity_allowed_while_suspended?
|
||||
%w(Delete Reject Undo Update).include?(@json['type'])
|
||||
end
|
||||
|
||||
def process_items(items)
|
||||
items.reverse_each.map { |item| process_item(item) }.compact
|
||||
end
|
||||
|
|
|
@ -16,7 +16,7 @@ class BlockDomainService < BaseService
|
|||
scope = Account.by_domain_and_subdomains(domain_block.domain)
|
||||
|
||||
scope.where(silenced_at: domain_block.created_at).in_batches.update_all(silenced_at: nil) unless domain_block.silence?
|
||||
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil) unless domain_block.suspend?
|
||||
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil, suspension_origin: nil) unless domain_block.suspend?
|
||||
end
|
||||
|
||||
def process_domain_block!
|
||||
|
@ -34,7 +34,8 @@ class BlockDomainService < BaseService
|
|||
end
|
||||
|
||||
def suspend_accounts!
|
||||
blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at)
|
||||
blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at, suspension_origin: :local)
|
||||
|
||||
blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account|
|
||||
DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
|
||||
end
|
||||
|
|
|
@ -64,8 +64,15 @@ class DeleteAccountService < BaseService
|
|||
def reject_follows!
|
||||
return if @account.local? || !@account.activitypub?
|
||||
|
||||
# When deleting a remote account, the account obviously doesn't
|
||||
# actually become deleted on its origin server, i.e. unlike a
|
||||
# locally deleted account it continues to have access to its home
|
||||
# feed and other content. To prevent it from being able to continue
|
||||
# to access toots it would receive because it follows local accounts,
|
||||
# we have to force it to unfollow them.
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
|
||||
[build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
|
||||
[Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -114,19 +121,20 @@ class DeleteAccountService < BaseService
|
|||
|
||||
return unless @options[:reserve_username]
|
||||
|
||||
@account.silenced_at = nil
|
||||
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
||||
@account.locked = false
|
||||
@account.memorial = false
|
||||
@account.discoverable = false
|
||||
@account.display_name = ''
|
||||
@account.note = ''
|
||||
@account.fields = []
|
||||
@account.statuses_count = 0
|
||||
@account.followers_count = 0
|
||||
@account.following_count = 0
|
||||
@account.moved_to_account = nil
|
||||
@account.trust_level = :untrusted
|
||||
@account.silenced_at = nil
|
||||
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
||||
@account.suspension_origin = :local
|
||||
@account.locked = false
|
||||
@account.memorial = false
|
||||
@account.discoverable = false
|
||||
@account.display_name = ''
|
||||
@account.note = ''
|
||||
@account.fields = []
|
||||
@account.statuses_count = 0
|
||||
@account.followers_count = 0
|
||||
@account.following_count = 0
|
||||
@account.moved_to_account = nil
|
||||
@account.trust_level = :untrusted
|
||||
@account.avatar.destroy
|
||||
@account.header.destroy
|
||||
@account.save!
|
||||
|
@ -154,10 +162,6 @@ class DeleteAccountService < BaseService
|
|||
@delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
|
||||
end
|
||||
|
||||
def build_reject_json(follow)
|
||||
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
|
||||
end
|
||||
|
||||
def delivery_inboxes
|
||||
@delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
|
||||
end
|
||||
|
|
|
@ -5,8 +5,6 @@ class ResolveAccountService < BaseService
|
|||
include DomainControlHelper
|
||||
include WebfingerHelper
|
||||
|
||||
class WebfingerRedirectError < StandardError; end
|
||||
|
||||
# Find or create an account record for a remote user. When creating,
|
||||
# look up the user's webfinger and fetch ActivityPub data
|
||||
# @param [String, Account] uri URI in the username@domain format or account record
|
||||
|
@ -40,13 +38,18 @@ class ResolveAccountService < BaseService
|
|||
|
||||
@account ||= Account.find_remote(@username, @domain)
|
||||
|
||||
return @account if @account&.local? || !webfinger_update_due?
|
||||
if gone_from_origin? && not_yet_deleted?
|
||||
queue_deletion!
|
||||
return
|
||||
end
|
||||
|
||||
return @account if @account&.local? || gone_from_origin? || !webfinger_update_due?
|
||||
|
||||
# Now it is certain, it is definitely a remote account, and it
|
||||
# either needs to be created, or updated from fresh data
|
||||
|
||||
process_account!
|
||||
rescue Webfinger::Error, WebfingerRedirectError, Oj::ParseError => e
|
||||
rescue Webfinger::Error, Oj::ParseError => e
|
||||
Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}"
|
||||
nil
|
||||
end
|
||||
|
@ -86,10 +89,12 @@ class ResolveAccountService < BaseService
|
|||
elsif !redirected
|
||||
return process_webfinger!("#{confirmed_username}@#{confirmed_domain}", true)
|
||||
else
|
||||
raise WebfingerRedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
|
||||
raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
|
||||
end
|
||||
|
||||
@domain = nil if TagManager.instance.local_domain?(@domain)
|
||||
rescue Webfinger::GoneError
|
||||
@gone = true
|
||||
end
|
||||
|
||||
def process_account!
|
||||
|
@ -131,6 +136,18 @@ class ResolveAccountService < BaseService
|
|||
@actor_json = supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) ? json : nil
|
||||
end
|
||||
|
||||
def gone_from_origin?
|
||||
@gone
|
||||
end
|
||||
|
||||
def not_yet_deleted?
|
||||
@account.present? && !@account.local?
|
||||
end
|
||||
|
||||
def queue_deletion!
|
||||
AccountDeletionWorker.perform_async(@account.id, reserve_username: false)
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "resolve:#{@username}@#{@domain}" }
|
||||
end
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SuspendAccountService < BaseService
|
||||
include Payloadable
|
||||
|
||||
def call(account)
|
||||
@account = account
|
||||
|
||||
suspend!
|
||||
reject_remote_follows!
|
||||
distribute_update_actor!
|
||||
unmerge_from_home_timelines!
|
||||
unmerge_from_list_timelines!
|
||||
privatize_media_attachments!
|
||||
|
@ -16,6 +20,31 @@ class SuspendAccountService < BaseService
|
|||
@account.suspend! unless @account.suspended?
|
||||
end
|
||||
|
||||
def reject_remote_follows!
|
||||
return if @account.local? || !@account.activitypub?
|
||||
|
||||
# When suspending a remote account, the account obviously doesn't
|
||||
# actually become suspended on its origin server, i.e. unlike a
|
||||
# locally suspended account it continues to have access to its home
|
||||
# feed and other content. To prevent it from being able to continue
|
||||
# to access toots it would receive because it follows local accounts,
|
||||
# we have to force it to unfollow them. Unfortunately, there is no
|
||||
# counterpart to this operation, i.e. you can't then force a remote
|
||||
# account to re-follow you, so this part is not reversible.
|
||||
|
||||
follows = Follow.where(account: @account).to_a
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(follows) do |follow|
|
||||
[Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url]
|
||||
end
|
||||
|
||||
follows.in_batches.destroy_all
|
||||
end
|
||||
|
||||
def distribute_update_actor!
|
||||
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) if @account.local?
|
||||
end
|
||||
|
||||
def unmerge_from_home_timelines!
|
||||
@account.followers_for_local_distribution.find_each do |follower|
|
||||
FeedManager.instance.unmerge_from_home(@account, follower)
|
||||
|
|
|
@ -13,6 +13,6 @@ class UnblockDomainService < BaseService
|
|||
scope = Account.by_domain_and_subdomains(domain_block.domain)
|
||||
|
||||
scope.where(silenced_at: domain_block.created_at).in_batches.update_all(silenced_at: nil) unless domain_block.noop?
|
||||
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil) if domain_block.suspend?
|
||||
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil, suspension_origin: nil) if domain_block.suspend?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,10 @@ class UnsuspendAccountService < BaseService
|
|||
@account = account
|
||||
|
||||
unsuspend!
|
||||
refresh_remote_account!
|
||||
|
||||
return if @account.nil?
|
||||
|
||||
merge_into_home_timelines!
|
||||
merge_into_list_timelines!
|
||||
publish_media_attachments!
|
||||
|
@ -16,6 +20,22 @@ class UnsuspendAccountService < BaseService
|
|||
@account.unsuspend! if @account.suspended?
|
||||
end
|
||||
|
||||
def refresh_remote_account!
|
||||
return if @account.local?
|
||||
|
||||
# While we had the remote account suspended, it could be that
|
||||
# it got suspended on its origin, too. So, we need to refresh
|
||||
# it straight away so it gets marked as remotely suspended in
|
||||
# that case.
|
||||
|
||||
@account.update!(last_webfingered_at: nil)
|
||||
@account = ResolveAccountService.new.call(@account)
|
||||
|
||||
# Worth noting that it is possible that the remote has not only
|
||||
# been suspended, but deleted permanently, in which case
|
||||
# @account would now be nil.
|
||||
end
|
||||
|
||||
def merge_into_home_timelines!
|
||||
@account.followers_for_local_distribution.find_each do |follower|
|
||||
FeedManager.instance.merge_into_home(@account, follower)
|
||||
|
|
|
@ -5,8 +5,8 @@ class AccountDeletionWorker
|
|||
|
||||
sidekiq_options queue: 'pull'
|
||||
|
||||
def perform(account_id)
|
||||
DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: false)
|
||||
def perform(account_id, reserve_username: true)
|
||||
DeleteAccountService.new.call(Account.find(account_id), reserve_username: reserve_username, reserve_email: false)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class AddSuspensionOriginToAccounts < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :accounts, :suspension_origin, :integer
|
||||
end
|
||||
end
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FillAccountSuspensionOrigin < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
Account.suspended.where(suspension_origin: nil).in_batches.update_all(suspension_origin: :local)
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2020_10_08_220312) do
|
||||
ActiveRecord::Schema.define(version: 2020_10_17_234926) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -189,6 +189,7 @@ ActiveRecord::Schema.define(version: 2020_10_08_220312) do
|
|||
t.integer "avatar_storage_schema_version"
|
||||
t.integer "header_storage_schema_version"
|
||||
t.string "devices_url"
|
||||
t.integer "suspension_origin"
|
||||
t.datetime "sensitized_at"
|
||||
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
|
||||
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
|
||||
|
|
|
@ -245,7 +245,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
if [404, 410].include?(code)
|
||||
SuspendAccountService.new.call(account, reserve_username: false) unless options[:dry_run]
|
||||
DeleteAccountService.new.call(account, reserve_username: false) unless options[:dry_run]
|
||||
1
|
||||
else
|
||||
# Touch account even during dry run to avoid getting the account into the window again
|
||||
|
|
|
@ -16,17 +16,49 @@ describe AccountFollowController do
|
|||
allow(service).to receive(:call)
|
||||
end
|
||||
|
||||
it 'does not create for user who is not signed in' do
|
||||
subject
|
||||
expect(FollowService).not_to receive(:new)
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
alice.suspend!
|
||||
alice.deletion_request.destroy
|
||||
subject
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
it 'redirects to account path' do
|
||||
sign_in(user)
|
||||
subject
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
alice.suspend!
|
||||
subject
|
||||
end
|
||||
|
||||
expect(service).to have_received(:call).with(user.account, alice, with_rate_limit: true)
|
||||
expect(response).to redirect_to(account_path(alice))
|
||||
it 'returns http forbidden' do
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed out' do
|
||||
before do
|
||||
subject
|
||||
end
|
||||
|
||||
it 'does not follow' do
|
||||
expect(FollowService).not_to receive(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed in' do
|
||||
before do
|
||||
sign_in(user)
|
||||
subject
|
||||
end
|
||||
|
||||
it 'redirects to account path' do
|
||||
expect(service).to have_received(:call).with(user.account, alice, with_rate_limit: true)
|
||||
expect(response).to redirect_to(account_path(alice))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -16,17 +16,49 @@ describe AccountUnfollowController do
|
|||
allow(service).to receive(:call)
|
||||
end
|
||||
|
||||
it 'does not create for user who is not signed in' do
|
||||
subject
|
||||
expect(UnfollowService).not_to receive(:new)
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
alice.suspend!
|
||||
alice.deletion_request.destroy
|
||||
subject
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
it 'redirects to account path' do
|
||||
sign_in(user)
|
||||
subject
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
alice.suspend!
|
||||
subject
|
||||
end
|
||||
|
||||
expect(service).to have_received(:call).with(user.account, alice)
|
||||
expect(response).to redirect_to(account_path(alice))
|
||||
it 'returns http forbidden' do
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed out' do
|
||||
before do
|
||||
subject
|
||||
end
|
||||
|
||||
it 'does not unfollow' do
|
||||
expect(UnfollowService).not_to receive(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed in' do
|
||||
before do
|
||||
sign_in(user)
|
||||
subject
|
||||
end
|
||||
|
||||
it 'redirects to account path' do
|
||||
expect(service).to have_received(:call).with(user.account, alice)
|
||||
expect(response).to redirect_to(account_path(alice))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -48,10 +48,17 @@ RSpec.describe AccountsController, type: :controller do
|
|||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is suspended' do
|
||||
context 'as HTML' do
|
||||
let(:format) { 'html' }
|
||||
|
||||
it_behaves_like 'preliminary checks'
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
|
@ -59,12 +66,17 @@ RSpec.describe AccountsController, type: :controller do
|
|||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as HTML' do
|
||||
let(:format) { 'html' }
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it_behaves_like 'preliminary checks'
|
||||
it 'returns http forbidden' do
|
||||
get :show, params: { username: account.username, format: format }
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'common response characteristics' do
|
||||
it 'returns http success' do
|
||||
|
@ -325,6 +337,29 @@ RSpec.describe AccountsController, type: :controller do
|
|||
|
||||
it_behaves_like 'preliminary checks'
|
||||
|
||||
context 'when account is suspended permanently' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
get :show, params: { username: account.username, format: format }
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is suspended temporarily' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
get :show, params: { username: account.username, format: format }
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context do
|
||||
before do
|
||||
get :show, params: { username: account.username, format: format }
|
||||
|
@ -435,6 +470,29 @@ RSpec.describe AccountsController, type: :controller do
|
|||
|
||||
it_behaves_like 'preliminary checks'
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
get :show, params: { username: account.username, format: format }
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http forbidden' do
|
||||
get :show, params: { username: account.username, format: format }
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'common response characteristics' do
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
|
|
|
@ -13,6 +13,7 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do
|
|||
end
|
||||
|
||||
it 'does not set sessions' do
|
||||
response
|
||||
expect(session).to be_empty
|
||||
end
|
||||
|
||||
|
@ -34,9 +35,8 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do
|
|||
context 'without signature' do
|
||||
let(:remote_account) { nil }
|
||||
|
||||
before do
|
||||
get :show, params: { id: 'featured', account_username: account.username }
|
||||
end
|
||||
subject(:response) { get :show, params: { id: 'featured', account_username: account.username } }
|
||||
subject(:body) { body_as_json }
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
|
@ -49,9 +49,29 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do
|
|||
it_behaves_like 'cachable response'
|
||||
|
||||
it 'returns orderedItems with pinned statuses' do
|
||||
json = body_as_json
|
||||
expect(json[:orderedItems]).to be_an Array
|
||||
expect(json[:orderedItems].size).to eq 2
|
||||
expect(body[:orderedItems]).to be_an Array
|
||||
expect(body[:orderedItems].size).to eq 2
|
||||
end
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http forbidden' do
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -32,9 +32,8 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController, type: :controll
|
|||
context 'with signature from example.com' do
|
||||
let(:remote_account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/instance') }
|
||||
|
||||
before do
|
||||
get :show, params: { account_username: account.username }
|
||||
end
|
||||
subject(:response) { get :show, params: { account_username: account.username } }
|
||||
subject(:body) { body_as_json }
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
|
@ -45,14 +44,34 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController, type: :controll
|
|||
end
|
||||
|
||||
it 'returns orderedItems with followers from example.com' do
|
||||
json = body_as_json
|
||||
expect(json[:orderedItems]).to be_an Array
|
||||
expect(json[:orderedItems].sort).to eq [follower_1.uri, follower_2.uri]
|
||||
expect(body[:orderedItems]).to be_an Array
|
||||
expect(body[:orderedItems].sort).to eq [follower_1.uri, follower_2.uri]
|
||||
end
|
||||
|
||||
it 'returns private Cache-Control header' do
|
||||
expect(response.headers['Cache-Control']).to eq 'max-age=0, private'
|
||||
end
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http forbidden' do
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,6 +20,33 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do
|
|||
it 'returns http accepted' do
|
||||
expect(response).to have_http_status(202)
|
||||
end
|
||||
|
||||
context 'for a specific account' do
|
||||
let(:account) { Fabricate(:account) }
|
||||
|
||||
subject(:response) { post :create, params: { account_username: account.username }, body: '{}' }
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http accepted' do
|
||||
expect(response).to have_http_status(202)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with Collection-Synchronization header' do
|
||||
|
|
|
@ -10,6 +10,7 @@ RSpec.describe ActivityPub::OutboxesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'does not set sessions' do
|
||||
response
|
||||
expect(session).to be_empty
|
||||
end
|
||||
|
||||
|
@ -34,9 +35,8 @@ RSpec.describe ActivityPub::OutboxesController, type: :controller do
|
|||
context 'without signature' do
|
||||
let(:remote_account) { nil }
|
||||
|
||||
before do
|
||||
get :show, params: { account_username: account.username, page: page }
|
||||
end
|
||||
subject(:response) { get :show, params: { account_username: account.username, page: page } }
|
||||
subject(:body) { body_as_json }
|
||||
|
||||
context 'with page not requested' do
|
||||
let(:page) { nil }
|
||||
|
@ -50,11 +50,31 @@ RSpec.describe ActivityPub::OutboxesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'returns totalItems' do
|
||||
json = body_as_json
|
||||
expect(json[:totalItems]).to eq 4
|
||||
expect(body[:totalItems]).to eq 4
|
||||
end
|
||||
|
||||
it_behaves_like 'cachable response'
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http forbidden' do
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with page requested' do
|
||||
|
@ -69,13 +89,33 @@ RSpec.describe ActivityPub::OutboxesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'returns orderedItems with public or unlisted statuses' do
|
||||
json = body_as_json
|
||||
expect(json[:orderedItems]).to be_an Array
|
||||
expect(json[:orderedItems].size).to eq 2
|
||||
expect(json[:orderedItems].all? { |item| item[:to].include?(ActivityPub::TagManager::COLLECTIONS[:public]) || item[:cc].include?(ActivityPub::TagManager::COLLECTIONS[:public]) }).to be true
|
||||
expect(body[:orderedItems]).to be_an Array
|
||||
expect(body[:orderedItems].size).to eq 2
|
||||
expect(body[:orderedItems].all? { |item| item[:to].include?(ActivityPub::TagManager::COLLECTIONS[:public]) || item[:cc].include?(ActivityPub::TagManager::COLLECTIONS[:public]) }).to be true
|
||||
end
|
||||
|
||||
it_behaves_like 'cachable response'
|
||||
|
||||
context 'when account is permanently suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
account.deletion_request.destroy
|
||||
end
|
||||
|
||||
it 'returns http gone' do
|
||||
expect(response).to have_http_status(410)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is temporarily suspended' do
|
||||
before do
|
||||
account.suspend!
|
||||
end
|
||||
|
||||
it 'returns http forbidden' do
|
||||
expect(response).to have_http_status(403 |