diff --git a/Changelog.md b/Changelog.md index 327e8bdd2..ebc879946 100644 --- a/Changelog.md +++ b/Changelog.md @@ -56,6 +56,7 @@ If so, please delete it since it will prevent the federation from working proper * Support cmd+enter to submit posts, comments and conversations [#7524](https://github.com/diaspora/diaspora/pull/7524) * Add markdown editor for posts, comments and conversations on mobile [#7235](https://github.com/diaspora/diaspora/pull/7235) * Mark as "Mobile Web App Capable" on Android [#7534](https://github.com/diaspora/diaspora/pull/7534) +* Add support for receiving account migrations [#6750](https://github.com/diaspora/diaspora/pull/6750) # 0.6.8.0 diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb new file mode 100644 index 000000000..9d2f451d5 --- /dev/null +++ b/app/models/account_migration.rb @@ -0,0 +1,165 @@ +class AccountMigration < ApplicationRecord + include Diaspora::Federated::Base + + belongs_to :old_person, class_name: "Person" + belongs_to :new_person, class_name: "Person" + + validates :old_person, uniqueness: true + validates :new_person, uniqueness: true + + after_create :lock_old_user! + + attr_accessor :old_private_key + + def receive(*) + perform! + end + + def public? + true + end + + def sender + @sender ||= old_user || ephemeral_sender + end + + # executes a migration plan according to this AccountMigration object + def perform! + raise "already performed" if performed? + + ActiveRecord::Base.transaction do + account_deleter.tombstone_person_and_profile + account_deleter.close_user if user_left_our_pod? + account_deleter.tombstone_user if user_changed_id_locally? + + update_all_references + end + + dispatch if locally_initiated? + dispatch_contacts if remotely_initiated? + end + + def performed? + old_person.closed_account? + end + + # We assume that migration message subscribers are people that are subscribed to a new user profile updates. + # Since during the migration we update contact references, this includes all the contacts of the old person. + # In case when a user migrated to our pod from a remote one, we include remote person to subscribers so that + # the new pod is informed about the migration as well. + def subscribers + new_user.profile.subscribers.remote.to_a.tap do |subscribers| + subscribers.push(old_person) if old_person.remote? + end + end + + private + + # Normally pod initiates migration locally when the new user is local. Then the pod creates AccountMigration object + # itself. If new user is remote, then AccountMigration object is normally received via the federation and this is + # remote initiation then. + def remotely_initiated? + new_person.remote? + end + + def locally_initiated? + !remotely_initiated? + end + + def old_user + old_person.owner + end + + def new_user + new_person.owner + end + + def lock_old_user! + old_user&.lock_access! + end + + def user_left_our_pod? + old_user && !new_user + end + + def user_changed_id_locally? + old_user && new_user + end + + # We need to resend contacts of users of our pod for the remote new person so that the remote pod received this + # contact information from the authoritative source. + def dispatch_contacts + new_person.contacts.sharing.each do |contact| + Diaspora::Federation::Dispatcher.defer_dispatch(contact.user, contact) + end + end + + def dispatch + Diaspora::Federation::Dispatcher.build(sender, self).dispatch + end + + EphemeralUser = Struct.new(:diaspora_handle, :serialized_private_key) do + def id + diaspora_handle + end + + def encryption_key + OpenSSL::PKey::RSA.new(serialized_private_key) + end + end + + def ephemeral_sender + raise "can't build sender without old private key defined" if old_private_key.nil? + EphemeralUser.new(old_person.diaspora_handle, old_private_key) + end + + def update_all_references + update_person_references + update_user_references if user_changed_id_locally? + end + + def person_references + references = Person.reflections.reject {|key, _| + %w[profile owner notifications pod].include?(key) + } + + references.map {|key, value| + {value.foreign_key => key} + } + end + + def user_references + references = User.reflections.reject {|key, _| + %w[ + person profile auto_follow_back_aspect invited_by aspect_memberships contact_people followed_tags + ignored_people conversation_visibilities pairwise_pseudonymous_identifiers conversations o_auth_applications + ].include?(key) + } + + references.map {|key, value| + {value.foreign_key => key} + } + end + + def update_person_references + logger.debug "Updating references from person id=#{old_person.id} to person id=#{new_person.id}" + update_references(person_references, old_person, new_person.id) + end + + def update_user_references + logger.debug "Updating references from user id=#{old_user.id} to user id=#{new_user.id}" + update_references(user_references, old_user, new_user.id) + end + + def update_references(references, object, new_id) + references.each do |pair| + key_id = pair.flatten[0] + association = pair.flatten[1] + object.send(association).update_all(key_id => new_id) + end + end + + def account_deleter + @account_deleter ||= AccountDeleter.new(old_person) + end +end diff --git a/app/models/person.rb b/app/models/person.rb index f8add5a52..87a0f02cf 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -40,7 +40,10 @@ class Person < ApplicationRecord has_many :likes, foreign_key: :author_id, dependent: :destroy # This person's own likes has_many :participations, :foreign_key => :author_id, :dependent => :destroy has_many :poll_participations, foreign_key: :author_id, dependent: :destroy - has_many :conversation_visibilities + has_many :conversation_visibilities, dependent: :destroy + has_many :messages, foreign_key: :author_id, dependent: :destroy + has_many :conversations, foreign_key: :author_id, dependent: :destroy + has_many :blocks, dependent: :destroy has_many :roles @@ -307,11 +310,6 @@ class Person < ApplicationRecord serialized_public_key end - def exported_key= new_key - raise "Don't change a key" if serialized_public_key - serialized_public_key = new_key - end - # discovery (webfinger) def self.find_or_fetch_by_identifier(diaspora_id) # exiting person? diff --git a/app/models/profile.rb b/app/models/profile.rb index dc5398133..949cf8f78 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -126,6 +126,7 @@ class Profile < ApplicationRecord end def tombstone! + @tag_string = nil self.taggings.delete_all clearable_fields.each do |field| self[field] = nil diff --git a/app/models/user.rb b/app/models/user.rb index 9c440402c..d596dd9d4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -54,6 +54,8 @@ class User < ApplicationRecord belongs_to :auto_follow_back_aspect, class_name: "Aspect", optional: true belongs_to :invited_by, class_name: "User", optional: true + has_many :invited_users, class_name: "User", inverse_of: :invited_by, foreign_key: :invited_by_id + has_many :aspect_memberships, :through => :aspects has_many :contacts diff --git a/db/migrate/20170730154117_create_account_migrations.rb b/db/migrate/20170730154117_create_account_migrations.rb new file mode 100644 index 000000000..b01eadbc6 --- /dev/null +++ b/db/migrate/20170730154117_create_account_migrations.rb @@ -0,0 +1,14 @@ +class CreateAccountMigrations < ActiveRecord::Migration[5.1] + def change + create_table :account_migrations do |t| + t.integer :old_person_id, null: false + t.integer :new_person_id, null: false + end + + add_foreign_key :account_migrations, :people, column: :old_person_id + add_foreign_key :account_migrations, :people, column: :new_person_id + + add_index :account_migrations, %i[old_person_id new_person_id], unique: true + add_index :account_migrations, :old_person_id, unique: true + end +end diff --git a/lib/account_deleter.rb b/lib/account_deleter.rb index 73787a702..95a5c9130 100644 --- a/lib/account_deleter.rb +++ b/lib/account_deleter.rb @@ -30,18 +30,20 @@ class AccountDeleter delete_contacts_of_me tombstone_person_and_profile - if self.user - #user deletion methods - remove_share_visibilities_on_contacts_posts - delete_standard_user_associations - disconnect_contacts - tombstone_user - end + close_user if user mark_account_deletion_complete end end + # user deletion methods + def close_user + remove_share_visibilities_on_contacts_posts + disconnect_contacts + delete_standard_user_associations + tombstone_user + end + #user deletions def normal_ar_user_associates_to_delete %i[tag_followings services aspects user_preferences @@ -53,7 +55,7 @@ class AccountDeleter end def ignored_ar_user_associations - %i[followed_tags invited_by contact_people aspect_memberships + %i[followed_tags invited_by invited_users contact_people aspect_memberships ignored_people share_visibilities conversation_visibilities conversations reports] end @@ -70,7 +72,7 @@ class AccountDeleter end def disconnect_contacts - user.contacts.reload.destroy_all + user.contacts.destroy_all end # Currently this would get deleted due to the db foreign key constrainsts, @@ -97,12 +99,12 @@ class AccountDeleter end def normal_ar_person_associates_to_delete - %i[posts photos mentions participations roles] + %i[posts photos mentions participations roles blocks] end def ignored_or_special_ar_person_associations %i[comments likes poll_participations contacts notification_actors notifications owner profile - conversation_visibilities pod] + conversation_visibilities pod conversations messages] end def mark_account_deletion_complete diff --git a/lib/diaspora/federation/entities.rb b/lib/diaspora/federation/entities.rb index 0165724fe..0a9bd8af4 100644 --- a/lib/diaspora/federation/entities.rb +++ b/lib/diaspora/federation/entities.rb @@ -22,6 +22,13 @@ module Diaspora ) end + def self.account_migration(account_migration) + DiasporaFederation::Entities::AccountMigration.new( + author: account_migration.sender.diaspora_handle, + profile: profile(account_migration.new_person.profile) + ) + end + def self.comment(comment) DiasporaFederation::Entities::Comment.new( { diff --git a/lib/diaspora/federation/mappings.rb b/lib/diaspora/federation/mappings.rb index 086df4685..ec344cab2 100644 --- a/lib/diaspora/federation/mappings.rb +++ b/lib/diaspora/federation/mappings.rb @@ -6,6 +6,7 @@ module Diaspora # used in Diaspora::Federation::Receive def self.receiver_for(federation_entity) case federation_entity + when DiasporaFederation::Entities::AccountMigration then :account_migration when DiasporaFederation::Entities::Comment then :comment when DiasporaFederation::Entities::Contact then :contact when DiasporaFederation::Entities::Conversation then :conversation @@ -24,6 +25,7 @@ module Diaspora # used in Diaspora::Federation::Entities def self.builder_for(diaspora_entity) case diaspora_entity + when AccountMigration then :account_migration when AccountDeletion then :account_deletion when Comment then :comment when Contact then :contact diff --git a/lib/diaspora/federation/receive.rb b/lib/diaspora/federation/receive.rb index 7b1faea26..d130cab68 100644 --- a/lib/diaspora/federation/receive.rb +++ b/lib/diaspora/federation/receive.rb @@ -11,6 +11,14 @@ module Diaspora AccountDeletion.create!(person: author_of(entity)) end + def self.account_migration(entity) + profile = profile(entity.profile) + AccountMigration.create!( + old_person: Person.by_account_identifier(entity.author), + new_person: profile.person + ) + end + def self.comment(entity) receive_relayable(Comment, entity) do Comment.new( diff --git a/spec/controllers/jasmine_fixtures/streams_spec.rb b/spec/controllers/jasmine_fixtures/streams_spec.rb index 5447a5420..0e815439a 100644 --- a/spec/controllers/jasmine_fixtures/streams_spec.rb +++ b/spec/controllers/jasmine_fixtures/streams_spec.rb @@ -5,6 +5,7 @@ describe StreamsController, :type => :controller do describe '#multi' do before do + allow(Workers::SendPublic).to receive(:perform_async) sign_in alice, scope: :user end diff --git a/spec/factories.rb b/spec/factories.rb index 02519966a..32a322933 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -53,6 +53,11 @@ FactoryGirl.define do association :person end + factory :account_migration do + association :old_person, factory: :person + association :new_person, factory: :person + end + factory :like do association :author, :factory => :person association :target, :factory => :status_message @@ -145,6 +150,11 @@ FactoryGirl.define do end end + factory(:share_visibility) do + user + association :shareable, factory: :status_message + end + factory(:location) do sequence(:address) {|n| "Fernsehturm Berlin, #{n}, Berlin, Germany" } sequence(:lat) {|n| 52.520645 + 0.0000001 * n } @@ -222,13 +232,8 @@ FactoryGirl.define do sequence(:uid) { |token| "00000#{token}" } sequence(:access_token) { |token| "12345#{token}" } sequence(:access_secret) { |token| "98765#{token}" } - end - factory :service_user do - sequence(:uid) { |id| "a#{id}"} - sequence(:name) { |num| "Rob Fergus the #{num.ordinalize}" } - association :service - photo_url "/assets/user/adams.jpg" + user end factory :pod do @@ -354,7 +359,18 @@ FactoryGirl.define do text SecureRandom.hex(1000) end - factory(:status, :parent => :status_message) + factory(:status, parent: :status_message) + + factory :block do + user + person + end + + factory :report do + user + association :item, factory: :status_message + text "offensive content" + end factory :o_auth_application, class: Api::OpenidConnect::OAuthApplication do client_name { "Diaspora Test Client #{r_str}" } diff --git a/spec/integration/account_deletion_spec.rb b/spec/integration/account_deletion_spec.rb index e9938c50c..b6cca15cf 100644 --- a/spec/integration/account_deletion_spec.rb +++ b/spec/integration/account_deletion_spec.rb @@ -12,25 +12,11 @@ describe "deleteing account", type: :request do DataGenerator.create(subject, :generic_user_data) end - it "deletes all of the user data" do - expect { - account_removal_method - }.to change(nil, "user preferences empty?") { UserPreference.where(user_id: user.id).empty? }.to(be_truthy) - .and(change(nil, "notifications empty?") { Notification.where(recipient_id: user.id).empty? }.to(be_truthy)) - .and(change(nil, "blocks empty?") { Block.where(user_id: user.id).empty? }.to(be_truthy)) - .and(change(nil, "services empty?") { Service.where(user_id: user.id).empty? }.to(be_truthy)) - .and(change(nil, "share visibilities empty?") { ShareVisibility.where(user_id: user.id).empty? }.to(be_truthy)) - .and(change(nil, "aspects empty?") { user.aspects.empty? }.to(be_truthy)) - .and(change(nil, "contacts empty?") { user.contacts.empty? }.to(be_truthy)) - .and(change(nil, "tag followings empty?") { user.tag_followings.empty? }.to(be_truthy)) - .and(change(nil, "clearable fields blank?") { - user.send(:clearable_fields).map {|field| - user.reload[field].blank? - } - }.to(eq([true] * user.send(:clearable_fields).count))) - end + it_behaves_like "deletes all of the user data" it_behaves_like "it removes the person associations" + + it_behaves_like "it keeps the person conversations" end context "of remote person" do @@ -41,5 +27,13 @@ describe "deleteing account", type: :request do end it_behaves_like "it removes the person associations" + + it_behaves_like "it keeps the person conversations" + + it_behaves_like "it makes account closed and clears profile" do + before do + account_removal_method + end + end end end diff --git a/spec/integration/account_migration_spec.rb b/spec/integration/account_migration_spec.rb new file mode 100644 index 000000000..b7559259e --- /dev/null +++ b/spec/integration/account_migration_spec.rb @@ -0,0 +1,204 @@ +require "integration/federation/federation_helper" + +def create_remote_contact(user, pod_host) + FactoryGirl.create( + :contact, + user: user, + person: FactoryGirl.create( + :person, + pod: Pod.find_or_create_by(url: "http://#{pod_host}"), + diaspora_handle: "#{r_str}@#{pod_host}" + ) + ) +end + +shared_examples_for "old person account is closed and profile is cleared" do + subject { old_user.person } + + before do + run_migration + subject.reload + end + + include_examples "it makes account closed and clears profile" +end + +shared_examples_for "old person doesn't have any reference left" do + let(:person) { old_user.person } + + before do + DataGenerator.create(person, :generic_person_data) + end + + def account_removal_method + run_migration + person.reload + end + + include_examples "it removes the person associations" + + include_examples "it removes the person conversations" +end + +shared_examples_for "every migration scenario" do + it_behaves_like "it updates person references" + + it_behaves_like "old person account is closed and profile is cleared" + + it_behaves_like "old person doesn't have any reference left" +end + +shared_examples_for "migration scenarios with local old user" do + it "locks the old user account" do + run_migration + expect(old_user.reload).to be_a_locked_account + end +end + +shared_examples_for "migration scenarios initiated remotely" do + it "resends known contacts to the new user" do + contacts = Array.new(2) { FactoryGirl.create(:contact, person: old_user.person, sharing: true) } + expect(DiasporaFederation::Federation::Sender).to receive(:private) + .twice do |sender_id, obj_str, _urls, _xml| + expect(sender_id).to eq(contacts.first.user_id) + expect(obj_str).to eq("Contact:#{contacts.first.user.diaspora_handle}:#{new_user.diaspora_handle}") + contacts.shift + [] + end + inlined_jobs { run_migration } + end +end + +shared_examples_for "migration scenarios initiated locally" do + it "dispatches account migration message to the federation" do + expect(DiasporaFederation::Federation::Sender).to receive(:public) do |sender_id, obj_str, urls, xml| + if old_user.person.remote? + expect(sender_id).to eq(old_user.diaspora_handle) + else + expect(sender_id).to eq(old_user.id) + end + expect(obj_str).to eq("AccountMigration:#{old_user.diaspora_handle}:#{new_user.diaspora_handle}") + subscribers = [remote_contact.person] + subscribers.push(old_user) if old_user.person.remote? + expect(urls).to match_array(subscribers.map(&:url).map {|url| "#{url}receive/public" }) + + entity = nil + expect { + magic_env = Nokogiri::XML(xml).root + entity = DiasporaFederation::Salmon::MagicEnvelope + .unenvelop(magic_env, old_user.diaspora_handle).payload + }.not_to raise_error + + expect(entity).to be_a(DiasporaFederation::Entities::AccountMigration) + expect(entity.author).to eq(old_user.diaspora_handle) + expect(entity.profile.author).to eq(new_user.diaspora_handle) + [] + end + + inlined_jobs do + run_migration + end + end +end + +describe "account migration" do + # this is the case when we receive account migration message from the federation + context "remotely initiated" do + let(:entity) { create_account_migration_entity(old_user.diaspora_handle, new_user) } + + def run_migration + allow_callbacks(%i[queue_public_receive fetch_public_key receive_entity]) + post_message(generate_payload(entity, old_user)) + end + + context "both new and old profiles are remote" do + include_context "with remote old user" + include_context "with remote new user" + + it "creates AccountMigration db object" do + run_migration + expect(AccountMigration.where(old_person: old_user.person, new_person: new_user.person)).to exist + end + + include_examples "every migration scenario" + + include_examples "migration scenarios initiated remotely" + end + + # this is the case when we're a pod, which was left by a person in favor of remote one + context "old user is local, new user is remote" do + include_context "with local old user" + include_context "with remote new user" + + include_examples "every migration scenario" + + include_examples "migration scenarios initiated remotely" + + it_behaves_like "migration scenarios with local old user" + + it_behaves_like "deletes all of the user data" do + let(:user) { old_user } + + before do + DataGenerator.create(user, :generic_user_data) + end + + def account_removal_method + run_migration + user.reload + end + end + end + end + + context "locally initiated" do + before do + allow(DiasporaFederation.callbacks).to receive(:trigger).and_call_original + end + + # this is the case when user migrates to our pod from a remote one + context "old user is remote and new user is local" do + include_context "with remote old user" + include_context "with local new user" + + def run_migration + AccountMigration.create!( + old_person: old_user.person, + new_person: new_user.person, + old_private_key: old_user.serialized_private_key + ).perform! + end + + include_examples "every migration scenario" + + it_behaves_like "migration scenarios initiated locally" do + let!(:remote_contact) { create_remote_contact(new_user, "remote-friend.org") } + end + end + + # this is the case when a user changes diaspora id but stays on the same pod + context "old user is local and new user is local" do + include_context "with local old user" + include_context "with local new user" + + def run_migration + AccountMigration.create!(old_person: old_user.person, new_person: new_user.person).perform! + end + + include_examples "every migration scenario" + + it_behaves_like "migration scenarios initiated locally" do + let!(:remote_contact) { create_remote_contact(old_user, "remote-friend.org") } + end + + it_behaves_like "migration scenarios with local old user" + + it "clears the old user account" do + run_migration + expect(old_user.reload).to be_a_clear_account + end + + it_behaves_like "it updates user references" + end + end +end diff --git a/spec/integration/federation/federation_helper.rb b/spec/integration/federation/federation_helper.rb index 58e95f908..f2da9812b 100644 --- a/spec/integration/federation/federation_helper.rb +++ b/spec/integration/federation/federation_helper.rb @@ -6,21 +6,43 @@ def remote_user_on_pod_c @remote_on_c ||= create_remote_user("remote-c.net") end -def create_remote_user(pod) +def allow_private_key_fetch(user) + allow(DiasporaFederation.callbacks).to receive(:trigger).with( + :fetch_private_key, user.diaspora_handle + ) { user.encryption_key } +end + +def allow_public_key_fetch(user) + allow(DiasporaFederation.callbacks).to receive(:trigger).with( + :fetch_public_key, user.diaspora_handle + ) { OpenSSL::PKey::RSA.new(user.person.serialized_public_key) } +end + +def create_undiscovered_user(pod) FactoryGirl.build(:user).tap do |user| allow(user).to receive(:person).and_return( - FactoryGirl.create(:person, - profile: FactoryGirl.build(:profile), - serialized_public_key: user.encryption_key.public_key.export, - pod: Pod.find_or_create_by(url: "http://#{pod}"), - diaspora_handle: "#{user.username}@#{pod}") + FactoryGirl.build(:person, + profile: FactoryGirl.build(:profile), + serialized_public_key: user.encryption_key.public_key.export, + pod: Pod.find_or_create_by(url: "http://#{pod}"), + diaspora_handle: "#{user.username}@#{pod}") ) - allow(DiasporaFederation.callbacks).to receive(:trigger).with( - :fetch_private_key, user.diaspora_handle - ) { user.encryption_key } - allow(DiasporaFederation.callbacks).to receive(:trigger).with( - :fetch_public_key, user.diaspora_handle - ) { OpenSSL::PKey::RSA.new(user.person.serialized_public_key) } + end +end + +def expect_person_discovery(undiscovered_user) + allow(Person).to receive(:find_or_fetch_by_identifier).with(any_args).and_call_original + expect(Person).to receive(:find_or_fetch_by_identifier).with(undiscovered_user.diaspora_handle) { + undiscovered_user.person.save! + undiscovered_user.person + } +end + +def create_remote_user(pod) + create_undiscovered_user(pod).tap do |user| + user.person.save! + allow_private_key_fetch(user) + allow_public_key_fetch(user) end end @@ -44,6 +66,14 @@ def create_relayable_entity(entity_name, parent, diaspora_id) ) end +def create_account_migration_entity(diaspora_id, new_user) + Fabricate( + :account_migration_entity, + author: diaspora_id, + profile: Diaspora::Federation::Entities.build(new_user.profile) + ) +end + def generate_payload(entity, remote_user, recipient=nil) magic_env = DiasporaFederation::Salmon::MagicEnvelope.new( entity, diff --git a/spec/integration/federation/receive_federation_messages_spec.rb b/spec/integration/federation/receive_federation_messages_spec.rb index f689d55d2..8ddb917cc 100644 --- a/spec/integration/federation/receive_federation_messages_spec.rb +++ b/spec/integration/federation/receive_federation_messages_spec.rb @@ -9,7 +9,7 @@ describe "Receive federation messages feature" do end let(:sender) { remote_user_on_pod_b } - let(:sender_id) { remote_user_on_pod_b.diaspora_handle } + let(:sender_id) { sender.diaspora_handle } context "with public receive" do let(:recipient) { nil } @@ -29,6 +29,80 @@ describe "Receive federation messages feature" do end end + context "account migration" do + # In case when sender is unknown we should just ignore the migration + # but this depends on https://github.com/diaspora/diaspora_federation/issues/72 + # which is low-priority, so we just discover the sender profile in this case. + # But there won't be a spec for that. + + let(:entity) { create_account_migration_entity(sender_id, new_user) } + + def run_migration + post_message(generate_payload(entity, sender)) + end + + context "with undiscovered new user profile" do + before do + allow_callbacks(%i[fetch_public_key]) + allow_private_key_fetch(new_user) + expect_person_discovery(new_user) + end + + let(:new_user) { create_undiscovered_user("example.org") } + + it "receives account migration correctly" do + run_migration + expect(AccountMigration.where(old_person: sender.person, new_person: new_user.person)).to exist + expect(AccountMigration.find_by(old_person: sender.person, new_person: new_user.person)).to be_performed + end + + it "doesn't accept the same migration for the second time" do + run_migration + expect { + run_migration + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "doesn't accept second migration for the same sender" do + run_migration + expect { + entity = create_account_migration_entity(sender_id, create_remote_user("example.org")) + post_message(generate_payload(entity, sender)) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "doesn't accept second migration for the same new user profile" do + run_migration + expect { + sender = create_remote_user("example.org") + entity = create_account_migration_entity(sender.diaspora_handle, new_user) + post_message(generate_payload(entity, sender)) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + context "when our pod was left" do + let(:sender) { FactoryGirl.create(:user) } + + it "locks the old user account access" do + run_migration + expect(sender.reload.access_locked?).to be_truthy + end + end + end + + context "with discovered profile" do + let(:new_user) { create_remote_user("example.org") } + + it "updates person profile with data from entity" do + new_user.profile.bio = "my updated biography" + expect(entity.profile.bio).to eq("my updated biography") + expect(new_user.profile.reload.bio).not_to eq("my updated biography") + run_migration + expect(new_user.profile.reload.bio).to eq("my updated biography") + end + end + end + context "reshare" do it "reshare of public post passes" do post = FactoryGirl.create(:status_message, author: alice.person, public: true) diff --git a/spec/lib/account_deleter_spec.rb b/spec/lib/account_deleter_spec.rb index 6546ff6c3..01552e3ab 100644 --- a/spec/lib/account_deleter_spec.rb +++ b/spec/lib/account_deleter_spec.rb @@ -14,12 +14,6 @@ describe AccountDeleter do end describe '#perform' do - user_removal_methods = %i[ - delete_standard_user_associations - remove_share_visibilities_on_contacts_posts - disconnect_contacts tombstone_user - ] - person_removal_methods = %i[ delete_contacts_of_me delete_standard_person_associations @@ -32,7 +26,7 @@ describe AccountDeleter do @account_deletion.perform! end - (user_removal_methods + person_removal_methods).each do |method| + [*person_removal_methods, :close_user].each do |method| it "calls ##{method.to_s}" do expect(@account_deletion).to receive(method) @@ -64,11 +58,8 @@ describe AccountDeleter do @person_deletion.perform! end - (user_removal_methods).each do |method| - - it "does not call ##{method.to_s}" do - expect(@person_deletion).not_to receive(method) - end + it "does not call #close_user" do + expect(@person_deletion).not_to receive(:close_user) end (person_removal_methods).each do |method| @@ -81,6 +72,24 @@ describe AccountDeleter do end + describe "#close_user" do + user_removal_methods = %i[ + delete_standard_user_associations + remove_share_visibilities_on_contacts_posts + disconnect_contacts tombstone_user + ] + + after do + @account_deletion.perform! + end + + user_removal_methods.each do |method| + it "calls ##{method}" do + expect(@account_deletion).to receive(method) + end + end + end + describe "#delete_standard_user_associations" do it 'removes all standard user associaltions' do @account_deletion.normal_ar_user_associates_to_delete.each do |asso| diff --git a/spec/lib/diaspora/federation/entities_spec.rb b/spec/lib/diaspora/federation/entities_spec.rb index fa3ace047..82bad8fb0 100644 --- a/spec/lib/diaspora/federation/entities_spec.rb +++ b/spec/lib/diaspora/federation/entities_spec.rb @@ -8,6 +8,16 @@ describe Diaspora::Federation::Entities do expect(federation_entity.author).to eq(diaspora_entity.person.diaspora_handle) end + it "builds an account migration" do + diaspora_entity = FactoryGirl.build(:account_migration) + diaspora_entity.old_private_key = OpenSSL::PKey::RSA.generate(1024).export + federation_entity = described_class.build(diaspora_entity) + + expect(federation_entity).to be_instance_of(DiasporaFederation::Entities::AccountMigration) + expect(federation_entity.author).to eq(diaspora_entity.old_person.diaspora_handle) + expect(federation_entity.profile.author).to eq(diaspora_entity.new_person.diaspora_handle) + end + it "builds a comment" do diaspora_entity = FactoryGirl.build(:comment) federation_entity = described_class.build(diaspora_entity) diff --git a/spec/lib/diaspora/federation/receive_spec.rb b/spec/lib/diaspora/federation/receive_spec.rb index d8b0dcaa5..a28dacf7f 100644 --- a/spec/lib/diaspora/federation/receive_spec.rb +++ b/spec/lib/diaspora/federation/receive_spec.rb @@ -12,6 +12,20 @@ describe Diaspora::Federation::Receive do end end + describe ".account_migration" do + let(:new_person) { FactoryGirl.create(:person) } + let(:profile_entity) { Fabricate(:profile_entity, author: new_person.diaspora_handle) } + let(:account_migration_entity) { + Fabricate(:account_migration_entity, author: sender.diaspora_handle, profile: profile_entity) + } + + it "saves the account deletion" do + Diaspora::Federation::Receive.account_migration(account_migration_entity) + + expect(AccountMigration.exists?(old_person: sender, new_person: new_person)).to be_truthy + end + end + describe ".comment" do let(:comment_entity) { build_relayable_federation_entity( diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb new file mode 100644 index 000000000..0f62a4d8b --- /dev/null +++ b/spec/models/account_migration_spec.rb @@ -0,0 +1,148 @@ +require "integration/federation/federation_helper" + +describe AccountMigration, type: :model do + describe "create!" do + include_context "with local old user" + + it "locks old local user after creation" do + expect { + AccountMigration.create!(old_person: old_person, new_person: FactoryGirl.create(:person)) + }.to change { old_user.reload.access_locked? }.to be_truthy + end + end + + let(:old_person) { FactoryGirl.create(:person) } + let(:new_person) { FactoryGirl.create(:person) } + let(:account_migration) { + AccountMigration.create!(old_person: old_person, new_person: new_person) + } + + describe "receive" do + it "calls perform!" do + expect(account_migration).to receive(:perform!) + account_migration.receive + end + end + + describe "sender" do + context "with remote old user" do + include_context "with remote old user" + + it "creates ephemeral user when private key is provided" do + account_migration.old_private_key = old_user.serialized_private_key + sender = account_migration.sender + expect(sender.id).to eq(old_user.diaspora_handle) + expect(sender.diaspora_handle).to eq(old_user.diaspora_handle) + expect(sender.encryption_key.to_s).to eq(old_user.encryption_key.to_s) + end + + it "raises when no private key is provided" do + expect { + account_migration.sender + }.to raise_error("can't build sender without old private key defined") + end + end + + context "with local old user" do + include_context "with local old user" + + it "matches the old user" do + expect(account_migration.sender).to eq(old_user) + end + end + end + + describe "performed?" do + it "is changed after perform!" do + expect { + account_migration.perform! + }.to change(account_migration, :performed?).to be_truthy + end + + it "calls old_person.closed_account?" do + expect(account_migration.old_person).to receive(:closed_account?) + account_migration.performed? + end + end + + context "with local new user" do + include_context "with local new user" + + describe "subscribers" do + it "picks remote subscribers of new user profile and old person" do + _local_friend, remote_contact = DataGenerator.create(new_user, %i[mutual_friend remote_mutual_friend]) + expect(account_migration.new_person.owner.profile).to receive(:subscribers).and_call_original + expect(account_migration.subscribers).to match_array([remote_contact.person, old_person]) + end + + context "with local old user" do + include_context "with local old user" + + it "doesn't include old person" do + expect(account_migration.subscribers).to be_empty + end + end + end + end + + describe "perform!" do + # TODO: add references update tests + # This spec is missing references update tests. We didn't come with a good idea of how to test it + # and it is currently covered by integration tests. But it's beter to add these tests at some point + # in future when we have more time to think about it. + + let(:embedded_account_deleter) { account_migration.send(:account_deleter) } + + it "raises if already performed" do + expect(account_migration).to receive(:performed?).and_return(true) + expect { + account_migration.perform! + }.to raise_error("already performed") + end + + it "calls AccountDeleter#tombstone_person_and_profile" do + expect(embedded_account_deleter).to receive(:tombstone_person_and_profile) + account_migration.perform! + end + + context "with local old and remote new users" do + include_context "with local old user" + + it "calls AccountDeleter#close_user" do + expect(embedded_account_deleter).to receive(:close_user) + account_migration.perform! + end + + it "resends contacts to the remote pod" do + contact = FactoryGirl.create(:contact, person: old_person, sharing: true) + expect(Diaspora::Federation::Dispatcher).to receive(:defer_dispatch).with(contact.user, contact) + account_migration.perform! + end + end + + context "with local new and remote old users" do + include_context "with remote old user" + include_context "with local new user" + + it "dispatches account migration message" do + expect(account_migration).to receive(:sender).and_return(old_user) + dispatcher = double + expect(dispatcher).to receive(:dispatch) + expect(Diaspora::Federation::Dispatcher).to receive(:build) + .with(old_user, account_migration) + .and_return(dispatcher) + account_migration.perform! + end + end + + context "with local old and new users" do + include_context "with local old user" + include_context "with local new user" + + it "calls AccountDeleter#tombstone_user" do + expect(embedded_account_deleter).to receive(:tombstone_user) + account_migration.perform! + end + end + end +end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index 8b6b5ab53..678dc677c 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -287,6 +287,12 @@ describe Profile, :type => :model do expect(@profile.taggings).to receive(:delete_all) @profile.tombstone! end + + it "doesn't recreate taggings if tag string was requested" do + @profile.tag_string + @profile.tombstone! + expect(@profile.taggings).to be_empty + end end describe "#clearable_fields" do diff --git a/spec/shared_behaviors/account_deletion.rb b/spec/shared_behaviors/account_deletion.rb index 19f7bf4fc..2f7b8f8d9 100644 --- a/spec/shared_behaviors/account_deletion.rb +++ b/spec/shared_behaviors/account_deletion.rb @@ -2,9 +2,27 @@ # licensed under the Affero General Public License version 3 or later. See # the COPYRIGHT file. -shared_examples_for "it removes the person associations" do - RSpec::Matchers.define_negated_matcher :remain, :change +shared_examples_for "deletes all of the user data" do + it "deletes all of the user data" do + expect(user).not_to be_a_clear_account + expect { + account_removal_method + }.to change(nil, "user preferences empty?") { UserPreference.where(user_id: user.id).empty? } + .to(be_truthy) + .and(change(nil, "notifications empty?") { Notification.where(recipient_id: user.id).empty? }.to(be_truthy)) + .and(change(nil, "blocks empty?") { Block.where(user_id: user.id).empty? }.to(be_truthy)) + .and(change(nil, "services empty?") { Service.where(user_id: user.id).empty? }.to(be_truthy)) + .and(change(nil, "share visibilities empty?") { ShareVisibility.where(user_id: user.id).empty? }.to(be_truthy)) + .and(change(nil, "aspects empty?") { user.aspects.empty? }.to(be_truthy)) + .and(change(nil, "contacts empty?") { user.contacts.empty? }.to(be_truthy)) + .and(change(nil, "tag followings empty?") { user.tag_followings.empty? }.to(be_truthy)) + + expect(user.reload).to be_a_clear_account + end +end + +shared_examples_for "it removes the person associations" do it "removes all of the person associations" do expect { account_removal_method @@ -20,9 +38,39 @@ shared_examples_for "it removes the person associations" do .and(change(nil, "conversation visibilities empty?") { ConversationVisibility.where(person_id: person.id).empty? }.to(be_truthy)) - .and(remain(nil, "conversations empty?") { Conversation.where(author: person).empty? }.from(be_falsey)) + end +end + +shared_examples_for "it keeps the person conversations" do + RSpec::Matchers.define_negated_matcher :remain, :change + + it "remains the person conversations" do + expect { + account_removal_method + }.to remain(nil, "conversations empty?") { Conversation.where(author: person).empty? } + .from(be_falsey) .and(remain(nil, "conversation visibilities of other participants empty?") { ConversationVisibility.where(conversation: Conversation.where(author: person)).empty? }.from(be_falsey)) end end + +shared_examples_for "it removes the person conversations" do + it "removes the person conversations" do + expect { + account_removal_method + }.to change(nil, "conversations empty?") { Conversation.where(author: person).empty? } + .to(be_truthy) + .and(change(nil, "conversation visibilities of other participants empty?") { + ConversationVisibility.where(conversation: Conversation.where(author: person)).empty? + }.to(be_truthy)) + end +end + +# In fact this example group if for testing effect of AccountDeleter.tombstone_person_and_profile +shared_examples_for "it makes account closed and clears profile" do + it "" do + expect(subject).to be_a_closed_account + expect(subject.profile).to be_a_clear_profile + end +end diff --git a/spec/shared_behaviors/account_migration.rb b/spec/shared_behaviors/account_migration.rb new file mode 100644 index 000000000..5da2f3b62 --- /dev/null +++ b/spec/shared_behaviors/account_migration.rb @@ -0,0 +1,187 @@ +shared_context "with local old user" do + let(:old_user) { FactoryGirl.create(:user) } + let(:old_person) { old_user.person } +end + +shared_context "with local new user" do + let(:new_user) { FactoryGirl.create(:user) } + let(:new_person) { new_user.person } +end + +shared_context "with remote old user" do + let(:old_user) { remote_user_on_pod_c } + let(:old_person) { old_user.person } +end + +shared_context "with remote new user" do + let(:new_user) { remote_user_on_pod_b } + let(:new_person) { new_user.person } +end + +shared_examples_for "it updates person references" do + it "updates contact reference" do + contact = FactoryGirl.create(:contact, person: old_person) + run_migration + expect(contact.reload.person).to eq(new_person) + end + + it "updates status message reference" do + post = FactoryGirl.create(:status_message, author: old_person) + run_migration + expect(post.reload.author).to eq(new_person) + end + + it "updates reshare reference" do + reshare = FactoryGirl.create(:reshare, author: old_person) + run_migration + expect(reshare.reload.author).to eq(new_person) + end + + it "updates photo reference" do + photo = FactoryGirl.create(:photo, author: old_person) + run_migration + expect(photo.reload.author).to eq(new_person) + end + + it "updates comment reference" do + comment = FactoryGirl.create(:comment, author: old_person) + run_migration + expect(comment.reload.author).to eq(new_person) + end + + it "updates like reference" do + like = FactoryGirl.create(:like, author: old_person) + run_migration + expect(like.reload.author).to eq(new_person) + end + + it "updates participations reference" do + participation = FactoryGirl.create(:participation, author: old_person) + run_migration + expect(participation.reload.author).to eq(new_person) + end + + it "updates poll participations reference" do + poll_participation = FactoryGirl.create(:poll_participation, author: old_person) + run_migration + expect(poll_participation.reload.author).to eq(new_person) + end + + it "updates conversation visibilities reference" do + conversation = FactoryGirl.build(:conversation) + FactoryGirl.create(:contact, user: old_user, person: conversation.author) if old_person.local? + conversation.participants << old_person + conversation.save! + visibility = ConversationVisibility.find_by(person_id: old_person.id) + run_migration + expect(visibility.reload.person).to eq(new_person) + end + + it "updates message reference" do + message = FactoryGirl.create(:message, author: old_person) + run_migration + expect(message.reload.author).to eq(new_person) + end + + it "updates conversation reference" do + conversation = FactoryGirl.create(:conversation, author: old_person) + run_migration + expect(conversation.reload.author).to eq(new_person) + end + + it "updates block references" do + user = FactoryGirl.create(:user) + block = user.blocks.create(person: old_person) + run_migration + expect(block.reload.person).to eq(new_person) + end + + it "updates role reference" do + role = FactoryGirl.create(:role, person: old_person) + run_migration + expect(role.reload.person).to eq(new_person) + end + + it "updates notification actors" do + notification = FactoryGirl.build(:notification) + notification.actors << old_person + notification.save! + actor = notification.notification_actors.find_by(person_id: old_person.id) + run_migration + expect(actor.reload.person).to eq(new_person) + end + + it "updates mention reference" do + mention = FactoryGirl.create(:mention, person: old_person) + run_migration + expect(mention.reload.person).to eq(new_person) + end +end + +shared_examples_for "it updates user references" do + it "updates invited users reference" do + invited_user = FactoryGirl.create(:user, invited_by: old_user) + run_migration + expect(invited_user.reload.invited_by).to eq(new_user) + end + + it "updates aspect reference" do + aspect = FactoryGirl.create(:aspect, user: old_user, name: r_str) + run_migration + expect(aspect.reload.user).to eq(new_user) + end + + it "updates contact reference" do + contact = FactoryGirl.create(:contact, user: old_user) + run_migration + expect(contact.reload.user).to eq(new_user) + end + + it "updates services reference" do + service = FactoryGirl.create(:service, user: old_user) + run_migration + expect(service.reload.user).to eq(new_user) + end + + it "updates user preference references" do + pref = UserPreference.create!(user: old_user, email_type: "also_commented") + run_migration + expect(pref.reload.user).to eq(new_user) + end + + it "updates tag following references" do + tag_following = FactoryGirl.create(:tag_following, user: old_user) + run_migration + expect(tag_following.reload.user).to eq(new_user) + end + + it "updates blocks refrences" do + block = FactoryGirl.create(:block, user: old_user) + run_migration + expect(block.reload.user).to eq(new_user) + end + + it "updates notification refrences" do + notification = FactoryGirl.create(:notification, recipient: old_user) + run_migration + expect(notification.reload.recipient).to eq(new_user) + end + + it "updates report refrences" do + report = FactoryGirl.create(:report, user: old_user) + run_migration + expect(report.reload.user).to eq(new_user) + end + + it "updates authorization refrences" do + authorization = FactoryGirl.create(:auth_with_read, user: old_user) + run_migration + expect(authorization.reload.user).to eq(new_user) + end + + it "updates share visibility refrences" do + share_visibility = FactoryGirl.create(:share_visibility, user: old_user) + run_migration + expect(share_visibility.reload.user).to eq(new_user) + end +end diff --git a/spec/spec/data_generator_spec.rb b/spec/spec/data_generator_spec.rb index 1c43041bd..1e2800245 100644 --- a/spec/spec/data_generator_spec.rb +++ b/spec/spec/data_generator_spec.rb @@ -14,6 +14,7 @@ describe DataGenerator do generator.generic_user_data expect(user.aspects).not_to be_empty expect(Post.subscribed_by(user)).not_to be_empty + expect(Contact.where(user: user).mutual).not_to be_empty end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d9389167a..493ee8b36 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -106,8 +106,6 @@ RSpec.configure do |config| I18n.locale = :en stub_request(:post, "https://pubsubhubbub.appspot.com/") $process_queue = false - allow(Workers::SendPublic).to receive(:perform_async) - allow(Workers::SendPrivate).to receive(:perform_async) end config.expect_with :rspec do |expect_config| diff --git a/spec/support/account_matchers.rb b/spec/support/account_matchers.rb new file mode 100644 index 000000000..758e2b583 --- /dev/null +++ b/spec/support/account_matchers.rb @@ -0,0 +1,43 @@ +RSpec::Matchers.define :be_a_discovered_person do + match do |person| + !Person.by_account_identifier(person.diaspora_handle).nil? + end +end + +RSpec::Matchers.define :be_a_closed_account do + match(&:closed_account?) +end + +RSpec::Matchers.define :be_a_locked_account do + match(&:access_locked?) +end + +RSpec::Matchers.define :be_a_clear_profile do + match do |profile| + attributes = %i[ + diaspora_handle first_name last_name image_url image_url_small image_url_medium birthday gender bio + location nsfw public_details + ].map {|attribute| profile[attribute] } + + profile.taggings.empty? && !profile.searchable && attributes.reject(&:nil?).empty? + end +end + +RSpec::Matchers.define :be_a_clear_account do + match do |user| + attributes = %i[ + language reset_password_token remember_created_at sign_in_count current_sign_in_at last_sign_in_at + current_sign_in_ip last_sign_in_ip invited_by_id authentication_token unconfirmed_email confirm_email_token + auto_follow_back auto_follow_back_aspect_id reset_password_sent_at last_seen color_theme + ].map {|attribute| user[attribute] } + + user.disable_mail && + user.strip_exif && + !user.getting_started && + !user.show_community_spotlight_in_stream && + !user.post_default_public && + user.email == "deletedaccount_#{user.id}@example.org" && + user.hidden_shareables.empty? && + attributes.reject(&:nil?).empty? + end +end diff --git a/spec/support/data_generator.rb b/spec/support/data_generator.rb index 7a3541f09..77194201d 100644 --- a/spec/support/data_generator.rb +++ b/spec/support/data_generator.rb @@ -38,6 +38,7 @@ class DataGenerator private_post_as_receipient tag_following generic_person_data + remote_mutual_friend end def generic_person_data @@ -98,6 +99,10 @@ class DataGenerator } end + def remote_mutual_friend + FactoryGirl.create(:contact, user: user, sharing: true, receiving: true) + end + def first_aspect user.aspects.first || FactoryGirl.create(:aspect, user: user) end diff --git a/spec/support/user_methods.rb b/spec/support/user_methods.rb index 9b5860809..48fec7aba 100644 --- a/spec/support/user_methods.rb +++ b/spec/support/user_methods.rb @@ -2,6 +2,8 @@ class User alias_method :share_with_original, :share_with def share_with(*args) + disable_send_workers + inlined_jobs do share_with_original(*args) end @@ -13,6 +15,8 @@ class User end def post(class_name, opts = {}) + disable_send_workers + inlined_jobs do aspects = self.aspects_from_ids(opts[:to]) @@ -22,11 +26,9 @@ class User self.aspects.reload dispatch_opts = { - url: Rails.application.routes.url_helpers.post_url( - p, - host: AppConfig.pod_uri.to_s - ), - to: opts[:to]} + url: Rails.application.routes.url_helpers.post_url(p, host: AppConfig.pod_uri.to_s), + to: opts[:to] + } dispatch_post(p, dispatch_opts) end unless opts[:created_at] @@ -40,4 +42,11 @@ class User def build_comment(options={}) Comment::Generator.new(self, options.delete(:post), options.delete(:text)).build(options) end + + def disable_send_workers + RSpec.current_example&.example_group_instance&.instance_eval do + allow(Workers::SendPrivate).to receive(:perform_async) + allow(Workers::SendPublic).to receive(:perform_async) + end + end end