From 5bc63185fdd8b071312cea3790f6dccc9ddbe9b2 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 9 May 2022 18:27:51 +0200 Subject: [PATCH] Add a CLI command to delete actors Signed-off-by: Thomas Citharel --- lib/mix/tasks/mobilizon/actors/delete.ex | 110 ++++++++++++++ test/tasks/actors/delete_test.exs | 178 +++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 lib/mix/tasks/mobilizon/actors/delete.ex create mode 100644 test/tasks/actors/delete_test.exs diff --git a/lib/mix/tasks/mobilizon/actors/delete.ex b/lib/mix/tasks/mobilizon/actors/delete.ex new file mode 100644 index 000000000..ffe6a686f --- /dev/null +++ b/lib/mix/tasks/mobilizon/actors/delete.ex @@ -0,0 +1,110 @@ +defmodule Mix.Tasks.Mobilizon.Actors.Delete do + @moduledoc """ + Task to delete an actor + """ + use Mix.Task + alias Mobilizon.{Actors, Users} + alias Mobilizon.Actors.Actor + alias Mobilizon.Service.ActorSuspension + alias Mobilizon.Users.User + import Mix.Tasks.Mobilizon.Common + + @shortdoc "Deletes a Mobilizon person or a group" + + @impl Mix.Task + def run([federated_username | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + assume_yes: :boolean, + keep_username: :boolean + ], + aliases: [ + y: :assume_yes, + k: :keep_username + ] + ) + + assume_yes? = Keyword.get(options, :assume_yes, false) + keep_username? = Keyword.get(options, :keep_username, false) + + start_mobilizon() + + # To make sure we can delete actors created by mistake with "@" in their username + case Actors.get_local_actor_by_name(federated_username) || + Actors.get_actor_by_name(federated_username) do + %Actor{preferred_username: username, domain: nil} when username in ["relay", "anonymous"] -> + shell_error("This actor can't be deleted.") + + %Actor{} = actor -> + if check_everything(actor, assume_yes?) do + ActorSuspension.suspend_actor(actor, + reserve_username: keep_username?, + suspension: false + ) + + display_name = Actor.display_name_and_username(actor) + + shell_info(""" + The actor #{display_name} has been deleted + """) + else + shell_error("Actor has not been deleted.") + end + + nil -> + shell_error("No actor found with this username") + end + end + + def run(_) do + shell_error( + "mobilizon.actors.delete requires an username or a federated username as argument" + ) + end + + @spec check_everything(Actor.t(), boolean()) :: boolean() + defp check_everything(%Actor{} = actor, assume_yes?) do + display_name = Actor.display_name_and_username(actor) + + (assume_yes? or + shell_yes?( + "All content by this profile or group will be deleted. Continue with deleting #{display_name}?" + )) and + check_actor(actor, assume_yes?) + end + + @spec check_actor(Actor.t(), boolean()) :: boolean() + defp check_actor(%Actor{type: :Group} = group, assume_yes?) do + display_name = Actor.display_name_and_username(group) + nb_members = Actors.count_members_for_group(group) + nb_followers = Actors.count_followers_for_actor(group) + + if nb_followers + nb_members > 0 do + shell_info("Group members will be notified of the group deletion.") + + assume_yes? or + shell_yes?( + "Group #{display_name} has #{nb_members} members and #{nb_followers} followers. Continue deleting?" + ) + else + true + end + end + + defp check_actor(%Actor{type: :Person, domain: nil} = profile, assume_yes?) do + %User{actors: actors, email: email} = Users.get_user_with_actors!(profile.user_id) + + if length(actors) == 1 do + assume_yes? or + shell_yes?( + "This profile is the only one user #{email} has. Mobilizon will invite the user to create a new profile on their next login. If you want to remove the whole user account, use the `mobilizon.users.delete` command. Continue deleting?" + ) + else + true + end + end + + defp check_actor(%Actor{} = _actor, assume_yes?), do: assume_yes? +end diff --git a/test/tasks/actors/delete_test.exs b/test/tasks/actors/delete_test.exs new file mode 100644 index 000000000..f3b7f9ad3 --- /dev/null +++ b/test/tasks/actors/delete_test.exs @@ -0,0 +1,178 @@ +defmodule Mix.Tasks.Mobilizon.Actors.DeleteTest do + use Mobilizon.DataCase + + import Mobilizon.Factory + + alias Mix.Tasks.Mobilizon.Actors.Delete + + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Federation.ActivityPub.Relay + alias Mobilizon.Users.User + + Mix.shell(Mix.Shell.Process) + + @preferred_username "toto" + @name "Léo Pandaï" + + describe "delete local profile" do + setup do + %User{} = user = insert(:user) + + %Actor{} = + profile = insert(:actor, user: user, preferred_username: @preferred_username, name: @name) + + {:ok, user: user, profile: profile} + end + + test "delete when username isn't set" do + Delete.run([]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "mobilizon.actors.delete requires an username or a federated username as argument" + end + + test "delete when no actor can't be found" do + Delete.run(["other_one"]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "No actor found with this username" + end + + test "delete with -y", %{profile: profile} do + Delete.run([@preferred_username, "-y"]) + + assert_received {:mix_shell, :info, [message]} + refute_received {:mix_shell, :yes?} + + assert message =~ + "The actor #{Actor.display_name_and_username(profile)} has been deleted" + + assert nil == Actors.get_actor_by_name(@preferred_username) + end + + test "delete while accepting", %{profile: profile} do + display_name = Actor.display_name_and_username(profile) + send(self(), {:mix_shell_input, :yes?, true}) + send(self(), {:mix_shell_input, :yes?, true}) + + Delete.run([@preferred_username]) + + assert_received {:mix_shell, :yes?, [input]} + + assert input == + "All content by this profile or group will be deleted. Continue with deleting #{display_name}?" + + assert_received {:mix_shell, :yes?, [input2]} + + assert input2 == + "This profile is the only one user #{profile.user.email} has. Mobilizon will invite the user to create a new profile on their next login. If you want to remove the whole user account, use the `mobilizon.users.delete` command. Continue deleting?" + + assert_received {:mix_shell, :info, [message2]} + assert message2 =~ "The actor #{display_name} has been deleted" + + assert nil == Actors.get_actor_by_name(@preferred_username) + end + end + + describe "delete group" do + @group_username "already_there" + @profile_username "theo" + + setup do + group = insert(:group, preferred_username: @group_username) + profile = insert(:actor, preferred_username: @profile_username) + insert(:member, parent: group, actor: profile, role: :administrator) + insert(:member, parent: group, role: :member) + {:ok, group: group, profile: profile} + end + + test "when everything is accepted", %{group: group} do + display_name = Actor.display_name_and_username(group) + send(self(), {:mix_shell_input, :yes?, true}) + send(self(), {:mix_shell_input, :yes?, true}) + Delete.run([@group_username]) + + assert_received {:mix_shell, :yes?, [input]} + + assert input == + "All content by this profile or group will be deleted. Continue with deleting #{display_name}?" + + assert_received {:mix_shell, :yes?, [input2]} + assert input2 == "Group #{display_name} has 2 members and 0 followers. Continue deleting?" + + assert_received {:mix_shell, :info, [message]} + assert message =~ "Group members will be notified of the group deletion." + assert_received {:mix_shell, :info, [message2]} + assert message2 =~ "The actor #{display_name} has been deleted" + + assert nil == Actors.get_actor_by_name(@preferred_username) + end + + test "when something is rejected", %{group: group} do + display_name = Actor.display_name_and_username(group) + send(self(), {:mix_shell_input, :yes?, false}) + Delete.run([@group_username]) + + assert_received {:mix_shell, :yes?, [input]} + + assert input == + "All content by this profile or group will be deleted. Continue with deleting #{display_name}?" + + assert_received {:mix_shell, :error, [message]} + assert message =~ "Actor has not been deleted." + + assert nil == Actors.get_actor_by_name(@preferred_username) + end + + test "with assume_yes option", %{group: group} do + display_name = Actor.display_name_and_username(group) + Delete.run([@group_username, "-y"]) + + refute_received {:mix_shell, :yes?} + + assert_received {:mix_shell, :info, [message]} + assert message =~ "Group members will be notified of the group deletion." + assert_received {:mix_shell, :info, [message2]} + assert message2 =~ "The actor #{display_name} has been deleted" + + assert nil == Actors.get_actor_by_name(@preferred_username) + end + + test "fails when called for an internal actor" do + Relay.get_actor() + Delete.run(["relay", "-y"]) + + assert_received {:mix_shell, :error, [message]} + assert message =~ "This actor can't be deleted." + end + end + + describe "delete something else" do + @actor_username "whatever" + + setup do + actor = insert(:actor, preferred_username: @actor_username, type: :Application) + {:ok, actor: actor} + end + + test "fails", %{actor: actor} do + display_name = Actor.display_name_and_username(actor) + send(self(), {:mix_shell_input, :yes?, true}) + Delete.run([@actor_username]) + assert_received {:mix_shell, :yes?, [input]} + + assert input == + "All content by this profile or group will be deleted. Continue with deleting #{display_name}?" + + assert_received {:mix_shell, :error, [message2]} + assert message2 =~ "Actor has not been deleted." + end + end +end