Commit cbc3900d authored by Benjamin Neff's avatar Benjamin Neff

Merge pull request #6750 from cmrd-senya/account_migration_message

Account migration model/message
parents d6225daa 45619cb1
......@@ -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
......
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
......@@ -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?
......
......@@ -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
......
......@@ -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
......
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
......@@ -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
......
......@@ -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(
{
......
......@@ -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
......
......@@ -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(
......
......@@ -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
......
......@@ -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}" }
......
......@@ -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
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
......@@ -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,
......