Repository: bookwyrm-social/bookwyrm

Issue:
Moving account between instances
**Describe the bug**
When trying to move an account from one instance to another, getting the error
Set this user as an alias on the user you are moving to first 
This happens even when the alias has been set.
**To Reproduce**
Steps to reproduce the behavior:
on 'new' account instance
1. Go to Account > Settings
2. Click on Aliases
3. enter a username in user@instance.com format
4. create alias
on 'old' account instance
1. Go to Account > Settings
2. click move account
3. enter new username in user@instance.com format and password
4. click move account

**Expected behavior**
Old account to be migrated to the new instance with followers directed to new account.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Instance**
the.me.uk, linux.me.uk

**Additional context**
This was discussed on the bookwyrm Element chat and the issue seems to relate to if the new account is known to the old server:
So we’re picking up the local version of the user instead of always fetching their info from the remote So we maybe want a flag like refetch or something to force it to check the remote.
(In handle_remote_webfinger)




Discussion:
I've had another look at the code for this and I'm not convinced our theory on Matrix is sound. There shouldn't normally be any reason that the new server hasn't picked up the change to the old account assuming activitypub broadcast tasks are running properly and there's not a big backlog - when users are saved, the change is broadcast to known servers by default.

`linux.me.uk` is timing out for me - i.e. it's not reachable. This seems more likely the problem.
> I've had another look at the code for this and I'm not convinced our theory on Matrix is sound. There shouldn't normally be any reason that the new server hasn't picked up the change to the old account assuming activitypub broadcast tasks are running properly and there's not a big backlog - when users are saved, the change is broadcast to known servers by default.
> 
> `linux.me.uk` is timing out for me - i.e. it's not reachable. This seems more likely the problem.

Hi,
thanks for looking at this. Linux.me.uk is down right now for cost reasons as its on a payg Azure instance. The instance was up and reachable for 4-5 days while this issue was ongoing. Other events were replicated between the instances. E.G if I rated a book on linux.me.uk I could see this on the.me.uk and vice versa.
I have brought linux.me.uk back up, but the same error continues.
@pi3r8 ok there's no need to keep linux.me.uk running now - I can see you have both users pointing at each other.

Because I can't log in to your server, I can't search for users but if you are logged in and go to https://the.me.uk/user/pi3r8@linux.me.uk.json can you tell me whether it shows your `the.me.uk` user as a value in `alsoKnownAs`?

Basically, every server will have a locally stored user for every _external_ user it knows about. We need to see whether the local version has been updated to match the values on the external server. If not there's either a problem with queues on the remote server (i.e. it's not sending) or some problem receiving and/or processing those messages. If the value _has_ been updated ...there's something else strange going on.
> @pi3r8 ok there's no need to keep linux.me.uk running now - I can see you have both users pointing at each other.
> 
> Because I can't log in to your server, I can't search for users but if you are logged in and go to https://the.me.uk/user/pi3r8@linux.me.uk.json can you tell me whether it shows your `the.me.uk` user as a value in `alsoKnownAs`?
> 
> Basically, every server will have a locally stored user for every _external_ user it knows about. We need to see whether the local version has been updated to match the values on the external server. If not there's either a problem with queues on the remote server (i.e. it's not sending) or some problem receiving and/or processing those messages. If the value _has_ been updated ...there's something else strange going on.

<html><body>
<!--StartFragment--><div class="toolbar"><div class="devtools-separator"></div><div class="devtools-searchbox"><input class="searchBox devtools-filterinput" placeholder="Filter JSON" value=""></div></div><div class="panelContent" id="json-scrolling-panel" tabindex="0">
  |  
-- | --
id | "https://linux.me.uk/user/pi3r8"
type | "Person"
preferredUsername | "pi3r8"
inbox | "https://linux.me.uk/user/pi3r8/inbox"
publicKey |  
id | "https://linux.me.uk/user/pi3r8/#main-key"
owner | "https://linux.me.uk/user/pi3r8"
publicKeyPem | "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjvYxM303mmbE7INdQVSz\nuk419xj0OZBCTG2Hcd2Od57mmuMTRewPw1FzfmfKYt/GmDKKTojmQoLIf5988XL8\nVd2hlxv4uZ1cAS8FLY6OlnMisQtNPMUOPXQAwbqg2S5SVv3WXXJ40iv0GxPZieu4\n8UjY8WKmNDjb3PCSpEODf/omysNO8T6biGKlm0ESD/9wVzZm084dlx/JRXdB1IOu\no7jhAc3vO/TPy1CnAIwuVZcJONg6k7e8/mlVzNUPo0CeyJUkwG2ux91C8bCiU3bT\nWARbWV2u54yPILN+M4E+DG8fSkSFHfXGzHfUN2o/waYXzdHuoPbhbUvkh0tQ/fvW\nUQIDAQAB\n-----END PUBLIC KEY-----"
followers | "https://linux.me.uk/user/pi3r8/followers"
following | "https://linux.me.uk/user/pi3r8/following"
outbox | "https://linux.me.uk/user/pi3r8/outbox"
endpoints |  
sharedInbox | "https://linux.me.uk/inbox"
icon |  
type | "Image"
url | "https://the.me.uk/images/avatars/9c9664d7-c8e6-4498-96c0-fb145c407967.png"
name | "avatar for pi3r8@linux.me.uk"
@context | "https://www.w3.org/ns/activitystreams"
bookwyrmUser | true
manuallyApprovesFollowers | false
discoverable | true
hideFollows | false
alsoKnownAs | []
@context |  
0 | "https://www.w3.org/ns/activitystreams"
1 | "https://w3id.org/security/v1"
2 |  
manuallyApprovesFollowers | "as:manuallyApprovesFollowers"
schema | "http://schema.org#"
PropertyValue | "schema:PropertyValue"
value | "schema:value"
alsoKnownAs |  
@id | "as:alsoKnownAs"
@type | "@id"
movedTo |  
@id | "as:movedTo"
@type | "@id"

</div><!--EndFragment-->
</body>
</html>id	"https://linux.me.uk/user/pi3r8"
type	"Person"
preferredUsername	"pi3r8"
inbox	"https://linux.me.uk/user/pi3r8/inbox"
publicKey	
id	"https://linux.me.uk/user/pi3r8/#main-key"
owner	"https://linux.me.uk/user/pi3r8"
publicKeyPem	"Removed"
followers	"https://linux.me.uk/user/pi3r8/followers"
following	"https://linux.me.uk/user/pi3r8/following"
outbox	"https://linux.me.uk/user/pi3r8/outbox"
endpoints	
sharedInbox	"https://linux.me.uk/inbox"
icon	
type	"Image"
url	"https://the.me.uk/images/avatars/9c9664d7-c8e6-4498-96c0-fb145c407967.png"
name	"avatar for pi3r8@linux.me.uk"
@context	"https://www.w3.org/ns/activitystreams"
bookwyrmUser	true
manuallyApprovesFollowers	false
discoverable	true
hideFollows	false
alsoKnownAs	[]
@context	
0	"https://www.w3.org/ns/activitystreams"
1	"https://w3id.org/security/v1"
2	
manuallyApprovesFollowers	"as:manuallyApprovesFollowers"
schema	"[http://schema.org#](http://schema.org/#)"
PropertyValue	"schema:PropertyValue"
value	"schema:value"
alsoKnownAs	
@id	"as:alsoKnownAs"
@type	"@id"
movedTo	
@id	"as:movedTo"
@type	"@id"

Resolving patch:
diff --git a/bookwyrm/models/move.py b/bookwyrm/models/move.py
--- a/bookwyrm/models/move.py
+++ b/bookwyrm/models/move.py
@@ -10,7 +10,7 @@
 
 
 class Move(ActivityMixin, BookWyrmModel):
-    """migrating an activitypub user account"""
+    """migrating an activitypub object"""
 
     user = fields.ForeignKey(
         "User", on_delete=models.PROTECT, activitypub_field="actor"
diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py
--- a/bookwyrm/views/helpers.py
+++ b/bookwyrm/views/helpers.py
@@ -61,7 +61,7 @@ def is_bookwyrm_request(request):
     return True
 
 
-def handle_remote_webfinger(query, unknown_only=False):
+def handle_remote_webfinger(query, unknown_only=False, refresh=False):
     """webfingerin' other servers"""
     user = None
 
@@ -76,6 +76,11 @@ def handle_remote_webfinger(query, unknown_only=False):
         return None
 
     try:
+
+        if refresh:
+            # Always fetch the remote info - don't even bother checking the DB
+            raise models.User.DoesNotExist("remote_only is set to True")
+
         user = models.User.objects.get(username__iexact=query)
 
         if unknown_only:
@@ -93,7 +98,7 @@ def handle_remote_webfinger(query, unknown_only=False):
             if link.get("rel") == "self":
                 try:
                     user = activitypub.resolve_remote_id(
-                        link["href"], model=models.User
+                        link["href"], model=models.User, refresh=refresh
                     )
                 except (KeyError, activitypub.ActivitySerializerError):
                     return None
diff --git a/bookwyrm/views/preferences/move_user.py b/bookwyrm/views/preferences/move_user.py
--- a/bookwyrm/views/preferences/move_user.py
+++ b/bookwyrm/views/preferences/move_user.py
@@ -32,7 +32,7 @@ def post(self, request):
 
         if form.is_valid() and user.check_password(form.cleaned_data["password"]):
             username = form.cleaned_data["target"]
-            target = handle_remote_webfinger(username)
+            target = handle_remote_webfinger(username, refresh=True)
 
             try:
                 models.MoveUser.objects.create(
@@ -81,6 +81,7 @@ def post(self, request):
                 return TemplateResponse(request, "preferences/alias_user.html", data)
 
             user.also_known_as.add(remote_user.id)
+            user.save(broadcast=True)  # broadcast the alias
 
             return redirect("prefs-alias")
 


Test patch:
diff --git a/bookwyrm/tests/data/ap_user_move.json b/bookwyrm/tests/data/ap_user_move.json
new file mode 100644
--- /dev/null
+++ b/bookwyrm/tests/data/ap_user_move.json
@@ -0,0 +1,40 @@
+{
+    "@context": [
+        "https://www.w3.org/ns/activitystreams",
+        "https://w3id.org/security/v1",
+        {
+            "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+            "schema": "http://schema.org#",
+            "PropertyValue": "schema:PropertyValue",
+            "value": "schema:value"
+        }
+    ],
+    "id": "https://example.com/user/mouse",
+    "type": "Person",
+    "preferredUsername": "mouse",
+    "name": "MOUSE?? MOUSE!!",
+    "inbox": "https://example.com/user/mouse/inbox",
+    "outbox": "https://example.com/user/mouse/outbox",
+    "followers": "https://example.com/user/mouse/followers",
+    "following": "https://example.com/user/mouse/following",
+    "summary": "",
+    "publicKey": {
+        "id": "https://example.com/user/mouse/#main-key",
+        "owner": "https://example.com/user/mouse",
+        "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC6QisDrjOQvkRo/MqNmSYPwqtt\nCxg/8rCW+9jKbFUKvqjTeKVotEE85122v/DCvobCCdfQuYIFdVMk+dB1xJ0iPGPg\nyU79QHY22NdV9mFKA2qtXVVxb5cxpA4PlwOHM6PM/k8B+H09OUrop2aPUAYwy+vg\n+MXyz8bAXrIS1kq6fQIDAQAB\n-----END PUBLIC KEY-----"
+    },
+    "endpoints": {
+        "sharedInbox": "https://example.com/inbox"
+    },
+    "bookwyrmUser": true,
+    "manuallyApprovesFollowers": false,
+    "discoverable": false,
+    "alsoKnownAs": ["https://your.domain.here/user/rat"],
+    "devices": "https://friend.camp/users/tripofmice/collections/devices",
+    "tag": [],
+    "icon": {
+        "type": "Image",
+        "mediaType": "image/png",
+        "url": "https://example.com/images/avatars/AL-2-crop-50.png"
+    }
+}
diff --git a/bookwyrm/tests/models/test_move.py b/bookwyrm/tests/models/test_move.py
new file mode 100644
--- /dev/null
+++ b/bookwyrm/tests/models/test_move.py
@@ -0,0 +1,60 @@
+""" testing move models """
+from unittest.mock import patch
+from django.core.exceptions import PermissionDenied
+from django.test import TestCase
+
+from bookwyrm import models
+
+
+class MoveUser(TestCase):
+    """move your account to another identity"""
+
+    @classmethod
+    def setUpTestData(cls):
+        """we need some users for this"""
+        with patch("bookwyrm.models.user.set_remote_server.delay"):
+            cls.target_user = models.User.objects.create_user(
+                "rat",
+                "rat@rat.com",
+                "ratword",
+                local=False,
+                remote_id="https://example.com/users/rat",
+                inbox="https://example.com/users/rat/inbox",
+                outbox="https://example.com/users/rat/outbox",
+            )
+
+        with (
+            patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
+            patch("bookwyrm.activitystreams.populate_stream_task.delay"),
+            patch("bookwyrm.lists_stream.populate_lists_task.delay"),
+        ):
+            cls.origin_user = models.User.objects.create_user(
+                "mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
+            )
+        cls.origin_user.remote_id = "http://local.com/user/mouse"
+        cls.origin_user.save(broadcast=False, update_fields=["remote_id"])
+
+    def test_user_move_unauthorized(self):
+        """attempt a user move without alsoKnownAs set"""
+
+        with self.assertRaises(PermissionDenied):
+            models.MoveUser.objects.create(
+                user=self.origin_user,
+                object=self.origin_user.remote_id,
+                target=self.target_user,
+            )
+
+    @patch("bookwyrm.suggested_users.remove_user_task.delay")
+    @patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
+    def test_user_move(self, *_):
+        """move user"""
+
+        self.target_user.also_known_as.add(self.origin_user.id)
+        self.target_user.save(broadcast=False)
+
+        models.MoveUser.objects.create(
+            user=self.origin_user,
+            object=self.origin_user.remote_id,
+            target=self.target_user,
+        )
+        self.assertEqual(self.origin_user.moved_to, self.target_user.remote_id)
diff --git a/bookwyrm/tests/views/preferences/test_move.py b/bookwyrm/tests/views/preferences/test_move.py
new file mode 100644
--- /dev/null
+++ b/bookwyrm/tests/views/preferences/test_move.py
@@ -0,0 +1,116 @@
+""" test move functionality """
+import json
+from unittest.mock import patch
+import pathlib
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.test import TestCase
+from django.test.client import RequestFactory
+import responses
+
+from bookwyrm import forms, models, views
+
+
+@patch("bookwyrm.activitystreams.add_status_task.delay")
+@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
+@patch("bookwyrm.activitystreams.populate_stream_task.delay")
+@patch("bookwyrm.suggested_users.rerank_user_task.delay")
+class ViewsHelpers(TestCase):
+    """viewing and creating statuses"""
+
+    @classmethod
+    def setUpTestData(cls):
+        """we need basic test data and mocks"""
+
+        with (
+            patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
+            patch("bookwyrm.activitystreams.populate_stream_task.delay"),
+            patch("bookwyrm.lists_stream.populate_lists_task.delay"),
+            patch("bookwyrm.suggested_users.rerank_user_task.delay"),
+        ):
+
+            cls.local_user = models.User.objects.create_user(
+                "rat",
+                "rat@rat.com",
+                "ratword",
+                local=True,
+                discoverable=True,
+                localname="rat",
+                remote_id="https://your.domain.here/user/rat",
+            )
+
+        with (
+            patch("bookwyrm.models.user.set_remote_server.delay"),
+            patch("bookwyrm.suggested_users.rerank_user_task.delay"),
+        ):
+            cls.remote_user = models.User.objects.create_user(
+                "mouse@example.com",
+                "mouse@mouse.com",
+                "mouseword",
+                local=False,
+                remote_id="https://example.com/user/mouse",
+            )
+
+    def setUp(self):
+        """individual test setup"""
+        self.factory = RequestFactory()
+        datafile = pathlib.Path(__file__).parent.joinpath(
+            "../../data/ap_user_move.json"
+        )
+        self.userdata = json.loads(datafile.read_bytes())
+        del self.userdata["icon"]
+
+    @responses.activate
+    @patch("bookwyrm.models.user.set_remote_server.delay")
+    @patch("bookwyrm.suggested_users.remove_user_task.delay")
+    @patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
+    def test_move_user_view(self, *_):
+        """move user"""
+
+        self.assertEqual(self.remote_user.remote_id, "https://example.com/user/mouse")
+        self.assertIsNone(self.local_user.moved_to)
+        self.assertIsNone(self.remote_user.moved_to)
+        self.assertIsNone(self.local_user.also_known_as.first())
+        self.assertIsNone(self.remote_user.also_known_as.first())
+
+        username = "mouse@example.com"
+        wellknown = {
+            "subject": "acct:mouse@example.com",
+            "links": [
+                {
+                    "rel": "self",
+                    "type": "application/activity+json",
+                    "href": "https://example.com/user/mouse",
+                }
+            ],
+        }
+        responses.add(
+            responses.GET,
+            f"https://example.com/.well-known/webfinger?resource=acct:{username}",
+            json=wellknown,
+            status=200,
+        )
+        responses.add(
+            responses.GET,
+            "https://example.com/user/mouse",
+            json=self.userdata,
+            status=200,
+        )
+
+        view = views.MoveUser.as_view()
+        form = forms.MoveUserForm()
+        form.data["target"] = "mouse@example.com"
+        form.data["password"] = "ratword"
+
+        request = self.factory.post("", form.data)
+        request.user = self.local_user
+        middleware = SessionMiddleware()
+        middleware.process_request(request)
+        request.session.save()
+
+        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
+            view(request)
+            self.local_user.refresh_from_db()
+
+            self.assertEqual(self.local_user.also_known_as.first(), self.remote_user)
+            self.assertEqual(self.remote_user.also_known_as.first(), self.local_user)
+            self.assertEqual(self.local_user.moved_to, "https://example.com/user/mouse")