implement Move activities (#45)
authorsfr <sol@solfisher.com>
Mon, 4 Jul 2022 16:29:39 +0000 (16:29 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Mon, 4 Jul 2022 16:29:39 +0000 (16:29 +0000)
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/45
Co-authored-by: sfr <sol@solfisher.com>
Co-committed-by: sfr <sol@solfisher.com>
15 files changed:
CHANGELOG.md
docs/development/API/pleroma_api.md
lib/pleroma/following_relationship.ex
lib/pleroma/user.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/api_spec/operations/twitter_util_operation.ex
lib/pleroma/web/router.ex
lib/pleroma/web/twitter_api/controllers/util_controller.ex
test/fixtures/tesla_mock/https___lm.kazv.moe_users_mewmew.xml [new file with mode: 0644]
test/fixtures/tesla_mock/lm.kazv.moe_host_meta [new file with mode: 0644]
test/fixtures/tesla_mock/mewmew@lm.kazv.moe.json [new file with mode: 0644]
test/pleroma/user_test.exs
test/pleroma/web/activity_pub/activity_pub_test.exs
test/pleroma/web/twitter_api/util_controller_test.exs
test/support/http_request_mock.ex

index 2119c8e21633f392b97659bc1fe651773046956f..22d8b0db14f7bc41f457293478ce4762cdf8b118 100644 (file)
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 ## [Unreleased]
 
+### Added
+- Added move account API
+
 ### Removed
 - SSH frontend, to be potentially re-enabled via a bridge rather than wired into the main system
 - Gopher frontend, as above
index 2304291e54f43316a88ce483a5b9843f79c3a876..1c4c959f02e9453b77ff75fd6de7ebc82fde8e06 100644 (file)
@@ -342,6 +342,36 @@ See [Admin-API](admin_api.md)
 * Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise
 * Note: Currently, Mastodon has no API for changing email. If they add it in future it might be incompatible with Pleroma.
 
+## `/api/pleroma/move_account`
+### Move account
+* Method `POST`
+* Authentication: required
+* Params:
+    * `password`: user's password
+    * `target_account`: the nickname of the target account (e.g. `foo@example.org`)
+* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise
+* Note: This endpoint emits a `Move` activity to all followers of the current account. Some remote servers will automatically unfollow the current account and follow the target account upon seeing this, but this depends on the remote server implementation and cannot be guaranteed. For local followers , they will automatically unfollow and follow if and only if they have set the `allow_following_move` preference ("Allow auto-follow when following account moves").
+
+## `/api/pleroma/aliases`
+### Get aliases of the current account
+* Method `GET`
+* Authentication: required
+* Response: JSON. Returns `{"aliases": [alias, ...]}`, where `alias` is the nickname of an alias, e.g. `foo@example.org`.
+
+### Add alias to the current account
+* Method `PUT`
+* Authentication: required
+* Params:
+    * `alias`: the nickname of the alias to add, e.g. `foo@example.org`.
+* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise
+
+### Delete alias from the current account
+* Method `DELETE`
+* Authentication: required
+* Params:
+    * `alias`: the nickname of the alias to delete, e.g. `foo@example.org`.
+* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise
+
 # Pleroma Conversations
 
 Pleroma Conversations have the same general structure that Mastodon Conversations have. The behavior differs in the following ways when using these endpoints:
index a0c7e6e393a3ca5574832716738cbc1d480d3095..b101b9ee7d81e04499e274b2e4a265e2372aed6f 100644 (file)
@@ -194,12 +194,13 @@ defmodule Pleroma.FollowingRelationship do
     |> join(:inner, [r], f in assoc(r, :follower))
     |> where(following_id: ^origin.id)
     |> where([r, f], f.allow_following_move == true)
+    |> where([r, f], f.local == true)
     |> limit(50)
     |> preload([:follower])
     |> Repo.all()
     |> Enum.map(fn following_relationship ->
-      Repo.delete(following_relationship)
       Pleroma.Web.CommonAPI.follow(following_relationship.follower, target)
+      Pleroma.Web.CommonAPI.unfollow(following_relationship.follower, origin)
     end)
     |> case do
       [] ->
index dc6c661eaf03486f79d99a1045f80b24e33b75d0..b256c87e1f7d36047488e2929fe03c534e9dd79c 100644 (file)
@@ -2288,6 +2288,38 @@ defmodule Pleroma.User do
     |> update_and_set_cache()
   end
 
+  def alias_users(user) do
+    user.also_known_as
+    |> Enum.map(&User.get_cached_by_ap_id/1)
+    |> Enum.filter(fn user -> user != nil end)
+  end
+
+  def add_alias(user, new_alias_user) do
+    current_aliases = user.also_known_as || []
+    new_alias_ap_id = new_alias_user.ap_id
+
+    if new_alias_ap_id in current_aliases do
+      {:ok, user}
+    else
+      user
+      |> cast(%{also_known_as: current_aliases ++ [new_alias_ap_id]}, [:also_known_as])
+      |> update_and_set_cache()
+    end
+  end
+
+  def delete_alias(user, alias_user) do
+    current_aliases = user.also_known_as || []
+    alias_ap_id = alias_user.ap_id
+
+    if alias_ap_id in current_aliases do
+      user
+      |> cast(%{also_known_as: current_aliases -- [alias_ap_id]}, [:also_known_as])
+      |> update_and_set_cache()
+    else
+      {:error, :no_such_alias}
+    end
+  end
+
   # Internal function; public one is `deactivate/2`
   defp set_activation_status(user, status) do
     user
index 77f38f9f19bafb086bb2b4921a743d408c55f743..236181b079ea48b87fff174450c49fe271499086 100644 (file)
@@ -417,7 +417,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
       "type" => "Move",
       "actor" => origin.ap_id,
       "object" => origin.ap_id,
-      "target" => target.ap_id
+      "target" => target.ap_id,
+      "to" => [origin.follower_address]
     }
 
     with true <- origin.ap_id in target.also_known_as,
index 2a701066d409549e5fcdde77fcc04a0558cc7db9..4a2a246f5bbfd0e5e223fb17573f702fa15c4e76 100644 (file)
@@ -214,6 +214,146 @@ defmodule Pleroma.Web.ApiSpec.TwitterUtilOperation do
     }
   end
 
+  def move_account_operation do
+    %Operation{
+      tags: ["Account credentials"],
+      summary: "Move account",
+      security: [%{"oAuth" => ["write:accounts"]}],
+      operationId: "UtilController.move_account",
+      requestBody: request_body("Parameters", move_account_request(), required: true),
+      responses: %{
+        200 =>
+          Operation.response("Success", "application/json", %Schema{
+            type: :object,
+            properties: %{status: %Schema{type: :string, example: "success"}}
+          }),
+        400 => Operation.response("Error", "application/json", ApiError),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp move_account_request do
+    %Schema{
+      title: "MoveAccountRequest",
+      description: "POST body for moving the account",
+      type: :object,
+      required: [:password, :target_account],
+      properties: %{
+        password: %Schema{type: :string, description: "Current password"},
+        target_account: %Schema{
+          type: :string,
+          description: "The nickname of the target account to move to"
+        }
+      }
+    }
+  end
+
+  def list_aliases_operation do
+    %Operation{
+      tags: ["Account credentials"],
+      summary: "List account aliases",
+      security: [%{"oAuth" => ["read:accounts"]}],
+      operationId: "UtilController.list_aliases",
+      responses: %{
+        200 =>
+          Operation.response("Success", "application/json", %Schema{
+            type: :object,
+            properties: %{
+              aliases: %Schema{
+                type: :array,
+                items: %Schema{type: :string},
+                example: ["foo@example.org"]
+              }
+            }
+          }),
+        400 => Operation.response("Error", "application/json", ApiError),
+        403 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def add_alias_operation do
+    %Operation{
+      tags: ["Account credentials"],
+      summary: "Add an alias to this account",
+      security: [%{"oAuth" => ["write:accounts"]}],
+      operationId: "UtilController.add_alias",
+      requestBody: request_body("Parameters", add_alias_request(), required: true),
+      responses: %{
+        200 =>
+          Operation.response("Success", "application/json", %Schema{
+            type: :object,
+            properties: %{
+              status: %Schema{
+                type: :string,
+                example: "success"
+              }
+            }
+          }),
+        400 => Operation.response("Error", "application/json", ApiError),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp add_alias_request do
+    %Schema{
+      title: "AddAliasRequest",
+      description: "PUT body for adding aliases",
+      type: :object,
+      required: [:alias],
+      properties: %{
+        alias: %Schema{
+          type: :string,
+          description: "The nickname of the account to add to aliases"
+        }
+      }
+    }
+  end
+
+  def delete_alias_operation do
+    %Operation{
+      tags: ["Account credentials"],
+      summary: "Delete an alias from this account",
+      security: [%{"oAuth" => ["write:accounts"]}],
+      operationId: "UtilController.delete_alias",
+      requestBody: request_body("Parameters", delete_alias_request(), required: true),
+      responses: %{
+        200 =>
+          Operation.response("Success", "application/json", %Schema{
+            type: :object,
+            properties: %{
+              status: %Schema{
+                type: :string,
+                example: "success"
+              }
+            }
+          }),
+        400 => Operation.response("Error", "application/json", ApiError),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp delete_alias_request do
+    %Schema{
+      title: "DeleteAliasRequest",
+      description: "PUT body for deleting aliases",
+      type: :object,
+      required: [:alias],
+      properties: %{
+        alias: %Schema{
+          type: :string,
+          description: "The nickname of the account to delete from aliases"
+        }
+      }
+    }
+  end
+
   def healthcheck_operation do
     %Operation{
       tags: ["Accounts"],
index 7977fa61940083ebe8b1d5e6d63b42772732101e..b1817ef8b7851f3d9bb8cf6b68b8e9210ab6d7e2 100644 (file)
@@ -349,6 +349,11 @@ defmodule Pleroma.Web.Router do
     post("/delete_account", UtilController, :delete_account)
     put("/notification_settings", UtilController, :update_notificaton_settings)
     post("/disable_account", UtilController, :disable_account)
+    post("/move_account", UtilController, :move_account)
+
+    put("/aliases", UtilController, :add_alias)
+    get("/aliases", UtilController, :list_aliases)
+    delete("/aliases", UtilController, :delete_alias)
   end
 
   scope "/api/pleroma", Pleroma.Web.PleromaAPI do
index ccbef6d9f11a85c80b04b9d180c334f526a01d6b..b8abc666e90c6c2149aadf755bbd5a2dba75a776 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
   alias Pleroma.Emoji
   alias Pleroma.Healthcheck
   alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.Plugs.OAuthScopesPlug
   alias Pleroma.Web.WebFinger
@@ -26,7 +27,18 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
            :change_password,
            :delete_account,
            :update_notificaton_settings,
-           :disable_account
+           :disable_account,
+           :move_account,
+           :add_alias,
+           :delete_alias
+         ]
+  )
+
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["read:accounts"]}
+    when action in [
+           :list_aliases
          ]
   )
 
@@ -158,6 +170,91 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
     end
   end
 
+  def move_account(%{assigns: %{user: user}, body_params: body_params} = conn, %{}) do
+    case CommonAPI.Utils.confirm_current_password(user, body_params.password) do
+      {:ok, user} ->
+        with {:ok, target_user} <- find_or_fetch_user_by_nickname(body_params.target_account),
+             {:ok, _user} <- ActivityPub.move(user, target_user) do
+          json(conn, %{status: "success"})
+        else
+          {:not_found, _} ->
+            conn
+            |> put_status(404)
+            |> json(%{error: "Target account not found."})
+
+          {:error, error} ->
+            json(conn, %{error: error})
+        end
+
+      {:error, msg} ->
+        json(conn, %{error: msg})
+    end
+  end
+
+  def add_alias(%{assigns: %{user: user}, body_params: body_params} = conn, _) do
+    with {:ok, alias_user} <- find_user_by_nickname(body_params.alias),
+         {:ok, _user} <- user |> User.add_alias(alias_user) do
+      json(conn, %{status: "success"})
+    else
+      {:not_found, _} ->
+        conn
+        |> put_status(404)
+        |> json(%{error: "Target account does not exist."})
+
+      {:error, error} ->
+        json(conn, %{error: error})
+    end
+  end
+
+  def delete_alias(%{assigns: %{user: user}, body_params: body_params} = conn, _) do
+    with {:ok, alias_user} <- find_user_by_nickname(body_params.alias),
+         {:ok, _user} <- user |> User.delete_alias(alias_user) do
+      json(conn, %{status: "success"})
+    else
+      {:error, :no_such_alias} ->
+        conn
+        |> put_status(404)
+        |> json(%{error: "Account has no such alias."})
+
+      {:error, error} ->
+        json(conn, %{error: error})
+    end
+  end
+
+  def list_aliases(%{assigns: %{user: user}} = conn, %{}) do
+    alias_nicks =
+      user
+      |> User.alias_users()
+      |> Enum.map(&User.full_nickname/1)
+
+    json(conn, %{aliases: alias_nicks})
+  end
+
+  defp find_user_by_nickname(nickname) do
+    user = User.get_cached_by_nickname(nickname)
+
+    if user == nil do
+      {:not_found, nil}
+    else
+      {:ok, user}
+    end
+  end
+
+  defp find_or_fetch_user_by_nickname(nickname) do
+    user = User.get_by_nickname(nickname)
+
+    if user != nil and user.local do
+      {:ok, user}
+    else
+      with {:ok, user} <- User.fetch_by_nickname(nickname) do
+        {:ok, user}
+      else
+        _ ->
+          {:not_found, nil}
+      end
+    end
+  end
+
   def captcha(conn, _params) do
     json(conn, Pleroma.Captcha.new())
   end
diff --git a/test/fixtures/tesla_mock/https___lm.kazv.moe_users_mewmew.xml b/test/fixtures/tesla_mock/https___lm.kazv.moe_users_mewmew.xml
new file mode 100644 (file)
index 0000000..b9e8dbb
--- /dev/null
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Subject>acct:mewmew@lm.kazv.moe</Subject><Alias>https://lm.kazv.moe/users/mewmew</Alias><Alias>https://lm.kazv.moe/users/tester</Alias><Alias>https://lm.kazv.moe/users/testuser</Alias><Link href="https://lm.kazv.moe/users/mewmew" rel="http://webfinger.net/rel/profile-page" type="text/html" /><Link href="https://lm.kazv.moe/users/mewmew" rel="self" type="application/activity+json" /><Link href="https://lm.kazv.moe/users/mewmew" rel="self" type="application/ld+json; profile=&quot;https://www.w3.org/ns/activitystreams&quot;" /><Link rel="http://ostatus.org/schema/1.0/subscribe" template="https://lm.kazv.moe/ostatus_subscribe?acct={uri}" /></XRD>
diff --git a/test/fixtures/tesla_mock/lm.kazv.moe_host_meta b/test/fixtures/tesla_mock/lm.kazv.moe_host_meta
new file mode 100644 (file)
index 0000000..02e6f05
--- /dev/null
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" template="https://lm.kazv.moe/.well-known/webfinger?resource={uri}" type="application/xrd+xml" /></XRD>
diff --git a/test/fixtures/tesla_mock/mewmew@lm.kazv.moe.json b/test/fixtures/tesla_mock/mewmew@lm.kazv.moe.json
new file mode 100644 (file)
index 0000000..8d2c3e1
--- /dev/null
@@ -0,0 +1 @@
+{"@context":["https://www.w3.org/ns/activitystreams","https://lm.kazv.moe/schemas/litepub-0.1.jsonld",{"@language":"und"}],"alsoKnownAs":["https://lm.kazv.moe/users/tester","https://lm.kazv.moe/users/testuser"],"attachment":[],"capabilities":{"acceptsChatMessages":true},"discoverable":false,"endpoints":{"oauthAuthorizationEndpoint":"https://lm.kazv.moe/oauth/authorize","oauthRegistrationEndpoint":"https://lm.kazv.moe/api/v1/apps","oauthTokenEndpoint":"https://lm.kazv.moe/oauth/token","sharedInbox":"https://lm.kazv.moe/inbox","uploadMedia":"https://lm.kazv.moe/api/ap/upload_media"},"featured":"https://lm.kazv.moe/users/mewmew/collections/featured","followers":"https://lm.kazv.moe/users/mewmew/followers","following":"https://lm.kazv.moe/users/mewmew/following","id":"https://lm.kazv.moe/users/mewmew","inbox":"https://lm.kazv.moe/users/mewmew/inbox","manuallyApprovesFollowers":false,"name":"mew","outbox":"https://lm.kazv.moe/users/mewmew/outbox","preferredUsername":"mewmew","publicKey":{"id":"https://lm.kazv.moe/users/mewmew#main-key","owner":"https://lm.kazv.moe/users/mewmew","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0nT3IVUwx799FSJyJEOY\n5D2c5zgtt2Z+BD9417eVLmVQF5fJlWgcKS4pbFc76zkYoBkZtV7XbzvN9KTNulpa\nUGNOM0/UdEoQLB8xbVCMm0ABUU8vbTWoMTxp93bfVHBz+33FPYdH1JHX4TCU/mJF\nX4UJMvFmMn5BFjSQm9GG6Eq2j6SAUsaTa8+Rrd8FzS6zb/dk3N/Llz0tfsZYS0sq\nEy9OYhsKOQ6eegULFJOF3Hz04vzwftmeXFsbb3aO2zKz3uAMYZglWHNBYJAePBtJ\ng362kqdJwgT14TFnZ0K2ziDPbkRULG1Kke/lsqw2rPF6Q6P4PeO1shCEDthoDoID\newIDAQAB\n-----END PUBLIC KEY-----\n\n"},"summary":"","tag":[],"type":"Person","url":"https://lm.kazv.moe/users/mewmew"}
index 756281a461724ac12dd86be39db5bbc8ac7d786d..8486987fd8b5c744d84b9c74b428c0a94aac9f95 100644 (file)
@@ -2521,4 +2521,80 @@ defmodule Pleroma.UserTest do
     %{object: %{data: %{"id" => object_id}}} = Activity.get_by_id_with_object(id)
     object_id
   end
+
+  describe "add_alias/2" do
+    test "should add alias for another user" do
+      user = insert(:user)
+      user2 = insert(:user)
+
+      assert {:ok, user_updated} = user |> User.add_alias(user2)
+
+      assert user_updated.also_known_as |> length() == 1
+      assert user2.ap_id in user_updated.also_known_as
+    end
+
+    test "should add multiple aliases" do
+      user = insert(:user)
+      user2 = insert(:user)
+      user3 = insert(:user)
+
+      assert {:ok, user} = user |> User.add_alias(user2)
+      assert {:ok, user_updated} = user |> User.add_alias(user3)
+
+      assert user_updated.also_known_as |> length() == 2
+      assert user2.ap_id in user_updated.also_known_as
+      assert user3.ap_id in user_updated.also_known_as
+    end
+
+    test "should not add duplicate aliases" do
+      user = insert(:user)
+      user2 = insert(:user)
+
+      assert {:ok, user} = user |> User.add_alias(user2)
+
+      assert {:ok, user_updated} = user |> User.add_alias(user2)
+
+      assert user_updated.also_known_as |> length() == 1
+      assert user2.ap_id in user_updated.also_known_as
+    end
+  end
+
+  describe "alias_users/1" do
+    test "should get aliases for a user" do
+      user = insert(:user)
+      user2 = insert(:user, also_known_as: [user.ap_id])
+
+      aliases = user2 |> User.alias_users()
+
+      assert aliases |> length() == 1
+
+      alias_user = aliases |> Enum.at(0)
+
+      assert alias_user.ap_id == user.ap_id
+    end
+  end
+
+  describe "delete_alias/2" do
+    test "should delete existing alias" do
+      user = insert(:user)
+      user2 = insert(:user, also_known_as: [user.ap_id])
+
+      assert {:ok, user_updated} = user2 |> User.delete_alias(user)
+
+      assert user_updated.also_known_as == []
+    end
+
+    test "should report error on non-existing alias" do
+      user = insert(:user)
+      user2 = insert(:user)
+      user3 = insert(:user, also_known_as: [user.ap_id])
+
+      assert {:error, :no_such_alias} = user3 |> User.delete_alias(user2)
+
+      user3_updated = User.get_cached_by_ap_id(user3.ap_id)
+
+      assert user3_updated.also_known_as |> length() == 1
+      assert user.ap_id in user3_updated.also_known_as
+    end
+  end
 end
index 2b65f59e0ac9335d54f83d6a6bafb04f79d69d82..a38d8a3fbabd274ee859ce7e06852376a866ef56 100644 (file)
@@ -1777,9 +1777,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
                  "target" => ^new_ap_id,
                  "type" => "Move"
                },
-               local: true
+               local: true,
+               recipients: recipients
              } = activity
 
+      assert old_user.follower_address in recipients
+
       params = %{
         "op" => "move_following",
         "origin_id" => old_user.id,
@@ -1810,6 +1813,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
       assert {:error, "Target account must have the origin in `alsoKnownAs`"} =
                ActivityPub.move(old_user, new_user)
     end
+
+    test "do not move remote user following relationships" do
+      %{ap_id: old_ap_id} = old_user = insert(:user)
+      %{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
+      follower_remote = insert(:user, local: false)
+
+      User.follow(follower_remote, old_user)
+
+      assert User.following?(follower_remote, old_user)
+
+      assert {:ok, activity} = ActivityPub.move(old_user, new_user)
+
+      assert %Activity{
+               actor: ^old_ap_id,
+               data: %{
+                 "actor" => ^old_ap_id,
+                 "object" => ^old_ap_id,
+                 "target" => ^new_ap_id,
+                 "type" => "Move"
+               },
+               local: true
+             } = activity
+
+      params = %{
+        "op" => "move_following",
+        "origin_id" => old_user.id,
+        "target_id" => new_user.id
+      }
+
+      assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
+
+      Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
+
+      assert User.following?(follower_remote, old_user)
+      refute User.following?(follower_remote, new_user)
+    end
   end
 
   test "doesn't retrieve replies activities with exclude_replies" do
index ee658ddf64f6f780ae10a27f82fc0d9b69f7790d..fb7da93f8fd6c0ab28986c88ee252670e5f7e342 100644 (file)
@@ -516,4 +516,371 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
       assert user.password_hash == nil
     end
   end
+
+  describe "POST /api/pleroma/move_account" do
+    setup do: oauth_access(["write:accounts"])
+
+    test "without permissions", %{conn: conn} do
+      target_user = insert(:user)
+      target_nick = target_user |> User.full_nickname()
+
+      conn =
+        conn
+        |> assign(:token, nil)
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "hi",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 403) == %{
+               "error" => "Insufficient permissions: write:accounts."
+             }
+    end
+
+    test "with proper permissions and invalid password", %{conn: conn} do
+      target_user = insert(:user)
+      target_nick = target_user |> User.full_nickname()
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "hi",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 200) == %{"error" => "Invalid password."}
+    end
+
+    test "with proper permissions, valid password and target account does not alias this",
+         %{
+           conn: conn
+         } do
+      target_user = insert(:user)
+      target_nick = target_user |> User.full_nickname()
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "test",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 200) == %{
+               "error" => "Target account must have the origin in `alsoKnownAs`"
+             }
+    end
+
+    test "with proper permissions, valid password and target account does not exist",
+         %{
+           conn: conn
+         } do
+      target_nick = "not_found@mastodon.social"
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "test",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 404) == %{
+               "error" => "Target account not found."
+             }
+    end
+
+    test "with proper permissions, valid password, remote target account aliases this and local cache does not exist",
+         %{} do
+      user = insert(:user, ap_id: "https://lm.kazv.moe/users/testuser")
+      %{user: _user, conn: conn} = oauth_access(["write:accounts"], user: user)
+
+      target_nick = "mewmew@lm.kazv.moe"
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "test",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"}
+    end
+
+    test "with proper permissions, valid password, remote target account aliases this and local cache does not alias this",
+         %{} do
+      user = insert(:user, ap_id: "https://lm.kazv.moe/users/testuser")
+      %{user: _user, conn: conn} = oauth_access(["write:accounts"], user: user)
+
+      target_user =
+        insert(
+          :user,
+          ap_id: "https://lm.kazv.moe/users/mewmew",
+          nickname: "mewmew@lm.kazv.moe",
+          local: false
+        )
+
+      target_nick = target_user |> User.full_nickname()
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "test",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"}
+    end
+
+    test "with proper permissions, valid password, remote target account does not alias this and local cache aliases this",
+         %{
+           user: user,
+           conn: conn
+         } do
+      target_user =
+        insert(
+          :user,
+          ap_id: "https://lm.kazv.moe/users/mewmew",
+          nickname: "mewmew@lm.kazv.moe",
+          local: false,
+          also_known_as: [user.ap_id]
+        )
+
+      target_nick = target_user |> User.full_nickname()
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post("/api/pleroma/move_account", %{
+          "password" => "test",
+          "target_account" => target_nick
+        })
+
+      assert json_response_and_validate_schema(conn, 200) == %{
+               "error" => "Target account must have the origin in `alsoKnownAs`"
+             }
+    end
+
+    test "with proper permissions, valid password and target account aliases this", %{
+      conn: conn,
+      user: user
+    } do
+      target_user = insert(:user, also_known_as: [user.ap_id])
+      target_nick = target_user |> User.full_nickname()
+      follower = insert(:user)
+
+      User.follow(follower, user)
+
+      assert User.following?(follower, user)
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post(
+          "/api/pleroma/move_account",
+          %{
+            password: "test",
+            target_account: target_nick
+          }
+        )
+
+      assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"}
+
+      params = %{
+        "op" => "move_following",
+        "origin_id" => user.id,
+        "target_id" => target_user.id
+      }
+
+      assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
+
+      Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
+
+      refute User.following?(follower, user)
+      assert User.following?(follower, target_user)
+    end
+
+    test "prefix nickname by @ should work", %{
+      conn: conn,
+      user: user
+    } do
+      target_user = insert(:user, also_known_as: [user.ap_id])
+      target_nick = target_user |> User.full_nickname()
+      follower = insert(:user)
+
+      User.follow(follower, user)
+
+      assert User.following?(follower, user)
+
+      conn =
+        conn
+        |> put_req_header("content-type", "multipart/form-data")
+        |> post(
+          "/api/pleroma/move_account",
+          %{
+            password: "test",
+            target_account: "@" <> target_nick
+          }
+        )
+
+      assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"}
+
+      params = %{
+        "op" => "move_following",
+        "origin_id" => user.id,
+        "target_id" => target_user.id
+      }
+
+      assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
+
+      Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
+
+      refute User.following?(follower, user)
+      assert User.following?(follower, target_user)
+    end
+  end
+
+  describe "GET /api/pleroma/aliases" do
+    setup do: oauth_access(["read:accounts"])
+
+    test "without permissions", %{conn: conn} do
+      conn =
+        conn
+        |> assign(:token, nil)
+        |> get("/api/pleroma/aliases")
+
+      assert json_response_and_validate_schema(conn, 403) == %{
+               "error" => "Insufficient permissions: read:accounts."
+             }
+    end
+
+    test "with permissions", %{
+      conn: conn
+    } do
+      assert %{"aliases" => []} =
+               conn
+               |> get("/api/pleroma/aliases")
+               |> json_response_and_validate_schema(200)
+    end
+
+    test "with permissions and aliases", %{} do
+      user = insert(:user)
+      user2 = insert(:user)
+
+      assert {:ok, user} = user |> User.add_alias(user2)
+
+      %{user: _user, conn: conn} = oauth_access(["read:accounts"], user: user)
+
+      assert %{"aliases" => aliases} =
+               conn
+               |> get("/api/pleroma/aliases")
+               |> json_response_and_validate_schema(200)
+
+      assert aliases == [user2 |> User.full_nickname()]
+    end
+  end
+
+  describe "PUT /api/pleroma/aliases" do
+    setup do: oauth_access(["write:accounts"])
+
+    test "without permissions", %{conn: conn} do
+      conn =
+        conn
+        |> assign(:token, nil)
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/pleroma/aliases", %{alias: "none"})
+
+      assert json_response_and_validate_schema(conn, 403) == %{
+               "error" => "Insufficient permissions: write:accounts."
+             }
+    end
+
+    test "with permissions, no alias param", %{
+      conn: conn
+    } do
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/pleroma/aliases", %{})
+
+      assert %{"error" => "Missing field: alias."} = json_response_and_validate_schema(conn, 400)
+    end
+
+    test "with permissions, with alias param", %{
+      conn: conn
+    } do
+      user2 = insert(:user)
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/pleroma/aliases", %{alias: user2 |> User.full_nickname()})
+
+      assert json_response_and_validate_schema(conn, 200) == %{
+               "status" => "success"
+             }
+    end
+  end
+
+  describe "DELETE /api/pleroma/aliases" do
+    setup do
+      alias_user = insert(:user)
+      non_alias_user = insert(:user)
+      user = insert(:user, also_known_as: [alias_user.ap_id])
+
+      oauth_access(["write:accounts"], user: user)
+      |> Map.put(:alias_user, alias_user)
+      |> Map.put(:non_alias_user, non_alias_user)
+    end
+
+    test "without permissions", %{conn: conn} do
+      conn =
+        conn
+        |> assign(:token, nil)
+        |> put_req_header("content-type", "application/json")
+        |> delete("/api/pleroma/aliases", %{alias: "none"})
+
+      assert json_response_and_validate_schema(conn, 403) == %{
+               "error" => "Insufficient permissions: write:accounts."
+             }
+    end
+
+    test "with permissions, no alias param", %{conn: conn} do
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> delete("/api/pleroma/aliases", %{})
+
+      assert %{"error" => "Missing field: alias."} = json_response_and_validate_schema(conn, 400)
+    end
+
+    test "with permissions, account does not have such alias", %{
+      conn: conn,
+      non_alias_user: non_alias_user
+    } do
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> delete("/api/pleroma/aliases", %{alias: non_alias_user |> User.full_nickname()})
+
+      assert %{"error" => "Account has no such alias."} =
+               json_response_and_validate_schema(conn, 404)
+    end
+
+    test "with permissions, account does have such alias", %{
+      conn: conn,
+      alias_user: alias_user
+    } do
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> delete("/api/pleroma/aliases", %{alias: alias_user |> User.full_nickname()})
+
+      assert %{"status" => "success"} = json_response_and_validate_schema(conn, 200)
+    end
+  end
 end
index 94900dc144944665c6912e30ecd86d355fbfc408..dfac773dec024aba0aa105ad9a2ba428de1ca613 100644 (file)
@@ -725,6 +725,15 @@ defmodule HttpRequestMock do
      }}
   end
 
+  def get(
+        "https://mastodon.social/.well-known/webfinger?resource=acct:not_found@mastodon.social",
+        _,
+        _,
+        [{"accept", "application/xrd+xml,application/jrd+json"}]
+      ) do
+    {:ok, %Tesla.Env{status: 404}}
+  end
+
   def get("http://gs.example.org/.well-known/host-meta", _, _, _) do
     {:ok,
      %Tesla.Env{
@@ -1124,6 +1133,57 @@ defmodule HttpRequestMock do
      }}
   end
 
+  def get("http://lm.kazv.moe/.well-known/host-meta", _, _, _) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body: File.read!("test/fixtures/tesla_mock/lm.kazv.moe_host_meta")
+     }}
+  end
+
+  def get("https://lm.kazv.moe/.well-known/host-meta", _, _, _) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body: File.read!("test/fixtures/tesla_mock/lm.kazv.moe_host_meta")
+     }}
+  end
+
+  def get(
+        "https://lm.kazv.moe/.well-known/webfinger?resource=acct:mewmew@lm.kazv.moe",
+        _,
+        _,
+        [{"accept", "application/xrd+xml,application/jrd+json"}]
+      ) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body: File.read!("test/fixtures/tesla_mock/https___lm.kazv.moe_users_mewmew.xml"),
+       headers: [{"content-type", "application/xrd+xml"}]
+     }}
+  end
+
+  def get("https://lm.kazv.moe/users/mewmew", _, _, _) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body: File.read!("test/fixtures/tesla_mock/mewmew@lm.kazv.moe.json"),
+       headers: activitypub_object_headers()
+     }}
+  end
+
+  def get("https://lm.kazv.moe/users/mewmew/collections/featured", _, _, _) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body:
+         File.read!("test/fixtures/users_mock/masto_featured.json")
+         |> String.replace("{{domain}}", "lm.kazv.moe")
+         |> String.replace("{{nickname}}", "mewmew"),
+       headers: [{"content-type", "application/activity+json"}]
+     }}
+  end
+
   def get("https://info.pleroma.site/activity.json", _, _, [
         {"accept", "application/activity+json"}
       ]) do