Merge branch 'feature/767-multiple-use-invite-token' into 'develop'
authorlambda <lain@soykaf.club>
Wed, 10 Apr 2019 10:10:08 +0000 (10:10 +0000)
committerlambda <lain@soykaf.club>
Wed, 10 Apr 2019 10:10:08 +0000 (10:10 +0000)
Feature/767 multiple use invite token

See merge request pleroma/pleroma!1032

14 files changed:
docs/api/admin_api.md
lib/mix/tasks/pleroma/user.ex
lib/pleroma/user_invite_token.ex
lib/pleroma/web/admin_api/admin_api_controller.ex
lib/pleroma/web/admin_api/views/account_view.ex
lib/pleroma/web/router.ex
lib/pleroma/web/twitter_api/twitter_api.ex
priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs [new file with mode: 0644]
test/fixtures/lambadalambda.json [new file with mode: 0644]
test/support/http_request_mock.ex
test/tasks/user_test.exs
test/user_invite_token_test.exs [new file with mode: 0644]
test/web/admin_api/admin_api_controller_test.exs
test/web/twitter_api/twitter_api_test.exs

index 86cacebb1e53a860711857031b2d6b912039e28b..8befa8ea0033bd57d1edad482f7ebe3d81e01649 100644 (file)
@@ -200,12 +200,65 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 
 ## `/api/pleroma/admin/invite_token`
 
-### Get a account registeration invite token
+### Get an account registration invite token
 
 - Methods: `GET`
-- Params: none
+- Params:
+  - *optional* `invite` => [
+    - *optional* `max_use` (integer)
+    - *optional* `expires_at` (date string e.g. "2019-04-07")
+  ]
 - Response: invite token (base64 string)
 
+## `/api/pleroma/admin/invites`
+
+### Get a list of generated invites
+
+- Methods: `GET`
+- Params: none
+- Response:
+
+```JSON
+{
+
+  "invites": [
+    {
+      "id": integer,
+      "token": string,
+      "used": boolean,
+      "expires_at": date,
+      "uses": integer,
+      "max_use": integer,
+      "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`)
+    },
+    ...
+  ]
+}
+```
+
+## `/api/pleroma/admin/revoke_invite`
+
+### Revoke invite by token
+
+- Methods: `POST`
+- Params:
+  - `token`
+- Response:
+
+```JSON
+{
+  "id": integer,
+  "token": string,
+  "used": boolean,
+  "expires_at": date,
+  "uses": integer,
+  "max_use": integer,
+  "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`)
+
+}
+```
+
+
 ## `/api/pleroma/admin/email_invite`
 
 ### Sends registration invite via email
@@ -213,7 +266,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 - Methods: `POST`
 - Params:
   - `email`
-  - `name`, optionnal
+  - `name`, optional
 
 ## `/api/pleroma/admin/password_reset`
 
index 0d0bea8c08a7333aad1edff65ef3a7c118df4a33..441168df2da7953c8a5a45e5cc3b25c16c2c92d6 100644 (file)
@@ -7,6 +7,7 @@ defmodule Mix.Tasks.Pleroma.User do
   import Ecto.Changeset
   alias Mix.Tasks.Pleroma.Common
   alias Pleroma.User
+  alias Pleroma.UserInviteToken
 
   @shortdoc "Manages Pleroma users"
   @moduledoc """
@@ -26,7 +27,19 @@ defmodule Mix.Tasks.Pleroma.User do
 
   ## Generate an invite link.
 
-      mix pleroma.user invite
+      mix pleroma.user invite [OPTION...]
+
+    Options:
+    - `--expires_at DATE` - last day on which token is active (e.g. "2019-04-05")
+    - `--max_use NUMBER` - maximum numbers of token uses
+
+  ## List generated invites
+
+      mix pleroma.user invites
+
+  ## Revoke invite
+
+      mix pleroma.user revoke_invite TOKEN OR TOKEN_ID
 
   ## Delete the user's account.
 
@@ -287,23 +300,79 @@ defmodule Mix.Tasks.Pleroma.User do
     end
   end
 
-  def run(["invite"]) do
+  def run(["invite" | rest]) do
+    {options, [], []} =
+      OptionParser.parse(rest,
+        strict: [
+          expires_at: :string,
+          max_use: :integer
+        ]
+      )
+
+    options =
+      options
+      |> Keyword.update(:expires_at, {:ok, nil}, fn
+        nil -> {:ok, nil}
+        val -> Date.from_iso8601(val)
+      end)
+      |> Enum.into(%{})
+
     Common.start_pleroma()
 
-    with {:ok, token} <- Pleroma.UserInviteToken.create_token() do
-      Mix.shell().info("Generated user invite token")
+    with {:ok, val} <- options[:expires_at],
+         options = Map.put(options, :expires_at, val),
+         {:ok, invite} <- UserInviteToken.create_invite(options) do
+      Mix.shell().info(
+        "Generated user invite token " <> String.replace(invite.invite_type, "_", " ")
+      )
 
       url =
         Pleroma.Web.Router.Helpers.redirect_url(
           Pleroma.Web.Endpoint,
           :registration_page,
-          token.token
+          invite.token
         )
 
       IO.puts(url)
     else
-      _ ->
-        Mix.shell().error("Could not create invite token.")
+      error ->
+        Mix.shell().error("Could not create invite token: #{inspect(error)}")
+    end
+  end
+
+  def run(["invites"]) do
+    Common.start_pleroma()
+
+    Mix.shell().info("Invites list:")
+
+    UserInviteToken.list_invites()
+    |> Enum.each(fn invite ->
+      expire_info =
+        with expires_at when not is_nil(expires_at) <- invite.expires_at do
+          " | Expires at: #{Date.to_string(expires_at)}"
+        end
+
+      using_info =
+        with max_use when not is_nil(max_use) <- invite.max_use do
+          " | Max use: #{max_use}    Left use: #{max_use - invite.uses}"
+        end
+
+      Mix.shell().info(
+        "ID: #{invite.id} | Token: #{invite.token} | Token type: #{invite.invite_type} | Used: #{
+          invite.used
+        }#{expire_info}#{using_info}"
+      )
+    end)
+  end
+
+  def run(["revoke_invite", token]) do
+    Common.start_pleroma()
+
+    with {:ok, invite} <- UserInviteToken.find_by_token(token),
+         {:ok, _} <- UserInviteToken.update_invite(invite, %{used: true}) do
+      Mix.shell().info("Invite for token #{token} was revoked.")
+    else
+      _ -> Mix.shell().error("No invite found with token #{token}")
     end
   end
 
index 9c5579934dddf6e68525992d1fcc7458e7da3997..86f0a548690c00ff7e482efa536688757f45a1aa 100644 (file)
@@ -6,40 +6,119 @@ defmodule Pleroma.UserInviteToken do
   use Ecto.Schema
 
   import Ecto.Changeset
-
+  import Ecto.Query
   alias Pleroma.Repo
   alias Pleroma.UserInviteToken
 
+  @type t :: %__MODULE__{}
+  @type token :: String.t()
+
   schema "user_invite_tokens" do
     field(:token, :string)
     field(:used, :boolean, default: false)
+    field(:max_use, :integer)
+    field(:expires_at, :date)
+    field(:uses, :integer, default: 0)
+    field(:invite_type, :string)
 
     timestamps()
   end
 
-  def create_token do
+  @spec create_invite(map()) :: UserInviteToken.t()
+  def create_invite(params \\ %{}) do
+    %UserInviteToken{}
+    |> cast(params, [:max_use, :expires_at])
+    |> add_token()
+    |> assign_type()
+    |> Repo.insert()
+  end
+
+  defp add_token(changeset) do
     token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
+    put_change(changeset, :token, token)
+  end
 
-    token = %UserInviteToken{
-      used: false,
-      token: token
-    }
+  defp assign_type(%{changes: %{max_use: _max_use, expires_at: _expires_at}} = changeset) do
+    put_change(changeset, :invite_type, "reusable_date_limited")
+  end
+
+  defp assign_type(%{changes: %{expires_at: _expires_at}} = changeset) do
+    put_change(changeset, :invite_type, "date_limited")
+  end
+
+  defp assign_type(%{changes: %{max_use: _max_use}} = changeset) do
+    put_change(changeset, :invite_type, "reusable")
+  end
+
+  defp assign_type(changeset), do: put_change(changeset, :invite_type, "one_time")
 
-    Repo.insert(token)
+  @spec list_invites() :: [UserInviteToken.t()]
+  def list_invites do
+    query = from(u in UserInviteToken, order_by: u.id)
+    Repo.all(query)
   end
 
-  def used_changeset(struct) do
-    struct
-    |> cast(%{}, [])
-    |> put_change(:used, true)
+  @spec update_invite!(UserInviteToken.t(), map()) :: UserInviteToken.t() | no_return()
+  def update_invite!(invite, changes) do
+    change(invite, changes) |> Repo.update!()
   end
 
-  def mark_as_used(token) do
-    with %{used: false} = token <- Repo.get_by(UserInviteToken, %{token: token}),
-         {:ok, token} <- Repo.update(used_changeset(token)) do
-      {:ok, token}
-    else
-      _e -> {:error, token}
+  @spec update_invite(UserInviteToken.t(), map()) ::
+          {:ok, UserInviteToken.t()} | {:error, Changeset.t()}
+  def update_invite(invite, changes) do
+    change(invite, changes) |> Repo.update()
+  end
+
+  @spec find_by_token!(token()) :: UserInviteToken.t() | no_return()
+  def find_by_token!(token), do: Repo.get_by!(UserInviteToken, token: token)
+
+  @spec find_by_token(token()) :: {:ok, UserInviteToken.t()} | nil
+  def find_by_token(token) do
+    with invite <- Repo.get_by(UserInviteToken, token: token) do
+      {:ok, invite}
     end
   end
+
+  @spec valid_invite?(UserInviteToken.t()) :: boolean()
+  def valid_invite?(%{invite_type: "one_time"} = invite) do
+    not invite.used
+  end
+
+  def valid_invite?(%{invite_type: "date_limited"} = invite) do
+    not_overdue_date?(invite) and not invite.used
+  end
+
+  def valid_invite?(%{invite_type: "reusable"} = invite) do
+    invite.uses < invite.max_use and not invite.used
+  end
+
+  def valid_invite?(%{invite_type: "reusable_date_limited"} = invite) do
+    not_overdue_date?(invite) and invite.uses < invite.max_use and not invite.used
+  end
+
+  defp not_overdue_date?(%{expires_at: expires_at}) do
+    Date.compare(Date.utc_today(), expires_at) in [:lt, :eq]
+  end
+
+  @spec update_usage!(UserInviteToken.t()) :: nil | UserInviteToken.t() | no_return()
+  def update_usage!(%{invite_type: "date_limited"}), do: nil
+
+  def update_usage!(%{invite_type: "one_time"} = invite),
+    do: update_invite!(invite, %{used: true})
+
+  def update_usage!(%{invite_type: invite_type} = invite)
+      when invite_type == "reusable" or invite_type == "reusable_date_limited" do
+    changes = %{
+      uses: invite.uses + 1
+    }
+
+    changes =
+      if changes.uses >= invite.max_use do
+        Map.put(changes, :used, true)
+      else
+        changes
+      end
+
+    update_invite!(invite, changes)
+  end
 end
index 78bf31893635ce60e4853b472ed1ef093e999036..70a5b5c5d7dbf4cfaf3821f9978c0453c0345175 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   use Pleroma.Web, :controller
   alias Pleroma.User
+  alias Pleroma.UserInviteToken
   alias Pleroma.Web.ActivityPub.Relay
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.AdminAPI.Search
@@ -235,7 +236,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     with true <-
            Pleroma.Config.get([:instance, :invites_enabled]) &&
              !Pleroma.Config.get([:instance, :registrations_open]),
-         {:ok, invite_token} <- Pleroma.UserInviteToken.create_token(),
+         {:ok, invite_token} <- UserInviteToken.create_invite(),
          email <-
            Pleroma.UserEmail.user_invitation_email(user, invite_token, email, params["name"]),
          {:ok, _} <- Pleroma.Mailer.deliver(email) do
@@ -244,11 +245,29 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   end
 
   @doc "Get a account registeration invite token (base64 string)"
-  def get_invite_token(conn, _params) do
-    {:ok, token} = Pleroma.UserInviteToken.create_token()
+  def get_invite_token(conn, params) do
+    options = params["invite"] || %{}
+    {:ok, invite} = UserInviteToken.create_invite(options)
 
     conn
-    |> json(token.token)
+    |> json(invite.token)
+  end
+
+  @doc "Get list of created invites"
+  def invites(conn, _params) do
+    invites = UserInviteToken.list_invites()
+
+    conn
+    |> json(AccountView.render("invites.json", %{invites: invites}))
+  end
+
+  @doc "Revokes invite by token"
+  def revoke_invite(conn, %{"token" => token}) do
+    invite = UserInviteToken.find_by_token!(token)
+    {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true})
+
+    conn
+    |> json(AccountView.render("invite.json", %{invite: updated_invite}))
   end
 
   @doc "Get a password reset token (base64 string) for given nickname"
index 4d6f921efc7ed032d1d230f0cdfa2abe6dc43fc6..28bb667d84d3a4ad39ecdc479a160dd5fe34e681 100644 (file)
@@ -26,4 +26,22 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
       "tags" => user.tags || []
     }
   end
+
+  def render("invite.json", %{invite: invite}) do
+    %{
+      "id" => invite.id,
+      "token" => invite.token,
+      "used" => invite.used,
+      "expires_at" => invite.expires_at,
+      "uses" => invite.uses,
+      "max_use" => invite.max_use,
+      "invite_type" => invite.invite_type
+    }
+  end
+
+  def render("invites.json", %{invites: invites}) do
+    %{
+      invites: render_many(invites, AccountView, "invite.json", as: :invite)
+    }
+  end
 end
index 06c8d7a03127c7e78f0fa412ae06cf458269c7a4..172f337db6556d6aba71dd3ea6ea0ea47022d68c 100644 (file)
@@ -168,6 +168,8 @@ defmodule Pleroma.Web.Router do
     delete("/relay", AdminAPIController, :relay_unfollow)
 
     get("/invite_token", AdminAPIController, :get_invite_token)
+    get("/invites", AdminAPIController, :invites)
+    post("/revoke_invite", AdminAPIController, :revoke_invite)
     post("/email_invite", AdminAPIController, :email_invite)
 
     get("/password_reset", AdminAPIController, :get_password_reset)
index 9b081a3167141ce4d2fcdc780df88aa55df21c46..9e9a46cf1ce93cf6f83f92ef4c112bf600817592 100644 (file)
@@ -129,7 +129,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
   end
 
   def register_user(params) do
-    token_string = params["token"]
+    token = params["token"]
 
     params = %{
       nickname: params["nickname"],
@@ -163,36 +163,49 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
       {:error, %{error: Jason.encode!(%{captcha: [error]})}}
     else
       registrations_open = Pleroma.Config.get([:instance, :registrations_open])
+      registration_process(registrations_open, params, token)
+    end
+  end
 
-      # no need to query DB if registration is open
-      token =
-        unless registrations_open || is_nil(token_string) do
-          Repo.get_by(UserInviteToken, %{token: token_string})
-        end
+  defp registration_process(registration_open, params, token)
+       when registration_open == false or is_nil(registration_open) do
+    invite =
+      unless is_nil(token) do
+        Repo.get_by(UserInviteToken, %{token: token})
+      end
 
-      cond do
-        registrations_open || (!is_nil(token) && !token.used) ->
-          changeset = User.register_changeset(%User{}, params)
+    valid_invite? = invite && UserInviteToken.valid_invite?(invite)
 
-          with {:ok, user} <- User.register(changeset) do
-            !registrations_open && UserInviteToken.mark_as_used(token.token)
+    case invite do
+      nil ->
+        {:error, "Invalid token"}
 
-            {:ok, user}
-          else
-            {:error, changeset} ->
-              errors =
-                Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
-                |> Jason.encode!()
+      invite when valid_invite? ->
+        UserInviteToken.update_usage!(invite)
+        create_user(params)
 
-              {:error, %{error: errors}}
-          end
+      _ ->
+        {:error, "Expired token"}
+    end
+  end
 
-        !registrations_open && is_nil(token) ->
-          {:error, "Invalid token"}
+  defp registration_process(true, params, _token) do
+    create_user(params)
+  end
 
-        !registrations_open && token.used ->
-          {:error, "Expired token"}
-      end
+  defp create_user(params) do
+    changeset = User.register_changeset(%User{}, params)
+
+    case User.register(changeset) do
+      {:ok, user} ->
+        {:ok, user}
+
+      {:error, changeset} ->
+        errors =
+          Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
+          |> Jason.encode!()
+
+        {:error, %{error: errors}}
     end
   end
 
diff --git a/priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs b/priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs
new file mode 100644 (file)
index 0000000..211a141
--- /dev/null
@@ -0,0 +1,12 @@
+defmodule Pleroma.Repo.Migrations.AddFieldsToUserInviteTokens do
+  use Ecto.Migration
+
+  def change do
+    alter table(:user_invite_tokens) do
+      add(:expires_at, :date)
+      add(:uses, :integer, default: 0)
+      add(:max_use, :integer)
+      add(:invite_type, :string, default: "one_time")
+    end
+  end
+end
diff --git a/test/fixtures/lambadalambda.json b/test/fixtures/lambadalambda.json
new file mode 100644 (file)
index 0000000..1f09fb5
--- /dev/null
@@ -0,0 +1,64 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    "https://w3id.org/security/v1",
+    {
+      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+      "toot": "http://joinmastodon.org/ns#",
+      "featured": {
+        "@id": "toot:featured",
+        "@type": "@id"
+      },
+      "alsoKnownAs": {
+        "@id": "as:alsoKnownAs",
+        "@type": "@id"
+      },
+      "movedTo": {
+        "@id": "as:movedTo",
+        "@type": "@id"
+      },
+      "schema": "http://schema.org#",
+      "PropertyValue": "schema:PropertyValue",
+      "value": "schema:value",
+      "Hashtag": "as:Hashtag",
+      "Emoji": "toot:Emoji",
+      "IdentityProof": "toot:IdentityProof",
+      "focalPoint": {
+        "@container": "@list",
+        "@id": "toot:focalPoint"
+      }
+    }
+  ],
+  "id": "https://mastodon.social/users/lambadalambda",
+  "type": "Person",
+  "following": "https://mastodon.social/users/lambadalambda/following",
+  "followers": "https://mastodon.social/users/lambadalambda/followers",
+  "inbox": "https://mastodon.social/users/lambadalambda/inbox",
+  "outbox": "https://mastodon.social/users/lambadalambda/outbox",
+  "featured": "https://mastodon.social/users/lambadalambda/collections/featured",
+  "preferredUsername": "lambadalambda",
+  "name": "Critical Value",
+  "summary": "\u003cp\u003e\u003c/p\u003e",
+  "url": "https://mastodon.social/@lambadalambda",
+  "manuallyApprovesFollowers": false,
+  "publicKey": {
+    "id": "https://mastodon.social/users/lambadalambda#main-key",
+    "owner": "https://mastodon.social/users/lambadalambda",
+    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n"
+  },
+  "tag": [],
+  "attachment": [],
+  "endpoints": {
+    "sharedInbox": "https://mastodon.social/inbox"
+  },
+  "icon": {
+    "type": "Image",
+    "mediaType": "image/gif",
+    "url": "https://files.mastodon.social/accounts/avatars/000/000/264/original/1429214160519.gif"
+  },
+  "image": {
+    "type": "Image",
+    "mediaType": "image/gif",
+    "url": "https://files.mastodon.social/accounts/headers/000/000/264/original/28b26104f83747d2.gif"
+  }
+}
index d3b547d91c89b5635ce39c84d75f410fdfeba8f1..5b355bfe6088c8f87f214baa992116a88eed05e9 100644 (file)
@@ -716,6 +716,10 @@ defmodule HttpRequestMock do
     {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.atom")}}
   end
 
+  def get("https://mastodon.social/users/lambadalambda", _, _, _) do
+    {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.json")}}
+  end
+
   def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/activity+json") do
     {:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)}
   end
index 1030bd555ef2bd154914899c1ee6ec8ccb7aa300..242265da5c48b68a75bd212d4e8ab65a446cd588 100644 (file)
@@ -245,7 +245,87 @@ defmodule Mix.Tasks.Pleroma.UserTest do
              end) =~ "http"
 
       assert_received {:mix_shell, :info, [message]}
-      assert message =~ "Generated"
+      assert message =~ "Generated user invite token one time"
+    end
+
+    test "token is generated with expires_at" do
+      assert capture_io(fn ->
+               Mix.Tasks.Pleroma.User.run([
+                 "invite",
+                 "--expires-at",
+                 Date.to_string(Date.utc_today())
+               ])
+             end)
+
+      assert_received {:mix_shell, :info, [message]}
+      assert message =~ "Generated user invite token date limited"
+    end
+
+    test "token is generated with max use" do
+      assert capture_io(fn ->
+               Mix.Tasks.Pleroma.User.run([
+                 "invite",
+                 "--max-use",
+                 "5"
+               ])
+             end)
+
+      assert_received {:mix_shell, :info, [message]}
+      assert message =~ "Generated user invite token reusable"
+    end
+
+    test "token is generated with max use and expires date" do
+      assert capture_io(fn ->
+               Mix.Tasks.Pleroma.User.run([
+                 "invite",
+                 "--max-use",
+                 "5",
+                 "--expires-at",
+                 Date.to_string(Date.utc_today())
+               ])
+             end)
+
+      assert_received {:mix_shell, :info, [message]}
+      assert message =~ "Generated user invite token reusable date limited"
+    end
+  end
+
+  describe "running invites" do
+    test "invites are listed" do
+      {:ok, invite} = Pleroma.UserInviteToken.create_invite()
+
+      {:ok, invite2} =
+        Pleroma.UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 15})
+
+      # assert capture_io(fn ->
+      Mix.Tasks.Pleroma.User.run([
+        "invites"
+      ])
+
+      #  end)
+
+      assert_received {:mix_shell, :info, [message]}
+      assert_received {:mix_shell, :info, [message2]}
+      assert_received {:mix_shell, :info, [message3]}
+      assert message =~ "Invites list:"
+      assert message2 =~ invite.invite_type
+      assert message3 =~ invite2.invite_type
+    end
+  end
+
+  describe "running revoke_invite" do
+    test "invite is revoked" do
+      {:ok, invite} = Pleroma.UserInviteToken.create_invite(%{expires_at: Date.utc_today()})
+
+      assert capture_io(fn ->
+               Mix.Tasks.Pleroma.User.run([
+                 "revoke_invite",
+                 invite.token
+               ])
+             end)
+
+      assert_received {:mix_shell, :info, [message]}
+      assert message =~ "Invite for token #{invite.token} was revoked."
     end
   end
 
diff --git a/test/user_invite_token_test.exs b/test/user_invite_token_test.exs
new file mode 100644 (file)
index 0000000..2767882
--- /dev/null
@@ -0,0 +1,96 @@
+defmodule Pleroma.UserInviteTokenTest do
+  use ExUnit.Case, async: true
+  use Pleroma.DataCase
+  alias Pleroma.UserInviteToken
+
+  describe "valid_invite?/1 one time invites" do
+    setup do
+      invite = %UserInviteToken{invite_type: "one_time"}
+
+      {:ok, invite: invite}
+    end
+
+    test "not used returns true", %{invite: invite} do
+      invite = %{invite | used: false}
+      assert UserInviteToken.valid_invite?(invite)
+    end
+
+    test "used  returns false", %{invite: invite} do
+      invite = %{invite | used: true}
+      refute UserInviteToken.valid_invite?(invite)
+    end
+  end
+
+  describe "valid_invite?/1 reusable invites" do
+    setup do
+      invite = %UserInviteToken{
+        invite_type: "reusable",
+        max_use: 5
+      }
+
+      {:ok, invite: invite}
+    end
+
+    test "with less uses then max use returns true", %{invite: invite} do
+      invite = %{invite | uses: 4}
+      assert UserInviteToken.valid_invite?(invite)
+    end
+
+    test "with equal or more uses then max use returns false", %{invite: invite} do
+      invite = %{invite | uses: 5}
+
+      refute UserInviteToken.valid_invite?(invite)
+
+      invite = %{invite | uses: 6}
+
+      refute UserInviteToken.valid_invite?(invite)
+    end
+  end
+
+  describe "valid_token?/1 date limited invites" do
+    setup do
+      invite = %UserInviteToken{invite_type: "date_limited"}
+      {:ok, invite: invite}
+    end
+
+    test "expires today returns true", %{invite: invite} do
+      invite = %{invite | expires_at: Date.utc_today()}
+      assert UserInviteToken.valid_invite?(invite)
+    end
+
+    test "expires yesterday returns false", %{invite: invite} do
+      invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)}
+      invite = Repo.insert!(invite)
+      refute UserInviteToken.valid_invite?(invite)
+    end
+  end
+
+  describe "valid_token?/1 reusable date limited invites" do
+    setup do
+      invite = %UserInviteToken{invite_type: "reusable_date_limited", max_use: 5}
+      {:ok, invite: invite}
+    end
+
+    test "not overdue date and less uses returns true", %{invite: invite} do
+      invite = %{invite | expires_at: Date.utc_today(), uses: 4}
+      assert UserInviteToken.valid_invite?(invite)
+    end
+
+    test "overdue date and less uses returns false", %{invite: invite} do
+      invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)}
+      invite = Repo.insert!(invite)
+      refute UserInviteToken.valid_invite?(invite)
+    end
+
+    test "not overdue date with more uses returns false", %{invite: invite} do
+      invite = %{invite | expires_at: Date.utc_today(), uses: 5}
+      refute UserInviteToken.valid_invite?(invite)
+    end
+
+    test "overdue date with more uses returns false", %{invite: invite} do
+      invite = %{invite | expires_at: Date.add(Date.utc_today(), -1), uses: 5}
+      invite = Repo.insert!(invite)
+      refute UserInviteToken.valid_invite?(invite)
+    end
+  end
+end
index ca6bd0e9764caa4fee6d7a55899ba2dcbb82f86f..d44392c9d0afa4f20e64c7996e3ef66a0a19bc2e 100644 (file)
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   use Pleroma.Web.ConnCase
 
   alias Pleroma.User
+  alias Pleroma.UserInviteToken
   import Pleroma.Factory
 
   describe "/api/pleroma/admin/user" do
@@ -640,4 +641,136 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
                "tags" => []
              }
   end
+
+  describe "GET /api/pleroma/admin/invite_token" do
+    test "without options" do
+      admin = insert(:user, info: %{is_admin: true})
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> get("/api/pleroma/admin/invite_token")
+
+      token = json_response(conn, 200)
+      invite = UserInviteToken.find_by_token!(token)
+      refute invite.used
+      refute invite.expires_at
+      refute invite.max_use
+      assert invite.invite_type == "one_time"
+    end
+
+    test "with expires_at" do
+      admin = insert(:user, info: %{is_admin: true})
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> get("/api/pleroma/admin/invite_token", %{
+          "invite" => %{"expires_at" => Date.to_string(Date.utc_today())}
+        })
+
+      token = json_response(conn, 200)
+      invite = UserInviteToken.find_by_token!(token)
+
+      refute invite.used
+      assert invite.expires_at == Date.utc_today()
+      refute invite.max_use
+      assert invite.invite_type == "date_limited"
+    end
+
+    test "with max_use" do
+      admin = insert(:user, info: %{is_admin: true})
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> get("/api/pleroma/admin/invite_token", %{
+          "invite" => %{"max_use" => 150}
+        })
+
+      token = json_response(conn, 200)
+      invite = UserInviteToken.find_by_token!(token)
+      refute invite.used
+      refute invite.expires_at
+      assert invite.max_use == 150
+      assert invite.invite_type == "reusable"
+    end
+
+    test "with max use and expires_at" do
+      admin = insert(:user, info: %{is_admin: true})
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> get("/api/pleroma/admin/invite_token", %{
+          "invite" => %{"max_use" => 150, "expires_at" => Date.to_string(Date.utc_today())}
+        })
+
+      token = json_response(conn, 200)
+      invite = UserInviteToken.find_by_token!(token)
+      refute invite.used
+      assert invite.expires_at == Date.utc_today()
+      assert invite.max_use == 150
+      assert invite.invite_type == "reusable_date_limited"
+    end
+  end
+
+  describe "GET /api/pleroma/admin/invites" do
+    test "no invites" do
+      admin = insert(:user, info: %{is_admin: true})
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> get("/api/pleroma/admin/invites")
+
+      assert json_response(conn, 200) == %{"invites" => []}
+    end
+
+    test "with invite" do
+      admin = insert(:user, info: %{is_admin: true})
+      {:ok, invite} = UserInviteToken.create_invite()
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> get("/api/pleroma/admin/invites")
+
+      assert json_response(conn, 200) == %{
+               "invites" => [
+                 %{
+                   "expires_at" => nil,
+                   "id" => invite.id,
+                   "invite_type" => "one_time",
+                   "max_use" => nil,
+                   "token" => invite.token,
+                   "used" => false,
+                   "uses" => 0
+                 }
+               ]
+             }
+    end
+  end
+
+  describe "POST /api/pleroma/admin/revoke_invite" do
+    test "with token" do
+      admin = insert(:user, info: %{is_admin: true})
+      {:ok, invite} = UserInviteToken.create_invite()
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> post("/api/pleroma/admin/revoke_invite", %{"token" => invite.token})
+
+      assert json_response(conn, 200) == %{
+               "expires_at" => nil,
+               "id" => invite.id,
+               "invite_type" => "one_time",
+               "max_use" => nil,
+               "token" => invite.token,
+               "used" => true,
+               "uses" => 0
+             }
+    end
+  end
 end
index 6c00244deb70ff1a8ffa866c4a76c9ddee2e30a1..a4540e6511a01a817240382422ca867c82b49df0 100644 (file)
@@ -16,6 +16,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
 
   import Pleroma.Factory
 
+  setup_all do
+    Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+    :ok
+  end
+
   test "create a status" do
     user = insert(:user)
     mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"})
@@ -299,7 +304,6 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
              UserView.render("show.json", %{user: fetched_user})
   end
 
-  @moduletag skip: "needs 'account_activation_required: true' in config"
   test "it sends confirmation email if :account_activation_required is specified in instance config" do
     setting = Pleroma.Config.get([:instance, :account_activation_required])
 
@@ -353,68 +357,313 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
     assert user2.bio == expected_text
   end
 
-  @moduletag skip: "needs 'registrations_open: false' in config"
-  test "it registers a new user via invite token and returns the user." do
-    {:ok, token} = UserInviteToken.create_token()
+  describe "register with one time token" do
+    setup do
+      setting = Pleroma.Config.get([:instance, :registrations_open])
 
-    data = %{
-      "nickname" => "vinny",
-      "email" => "pasta@pizza.vs",
-      "fullname" => "Vinny Vinesauce",
-      "bio" => "streamer",
-      "password" => "hiptofbees",
-      "confirm" => "hiptofbees",
-      "token" => token.token
-    }
+      if setting do
+        Pleroma.Config.put([:instance, :registrations_open], false)
+        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
+      end
 
-    {:ok, user} = TwitterAPI.register_user(data)
+      :ok
+    end
 
-    fetched_user = User.get_by_nickname("vinny")
-    token = Repo.get_by(UserInviteToken, token: token.token)
+    test "returns user on success" do
+      {:ok, invite} = UserInviteToken.create_invite()
 
-    assert token.used == true
+      data = %{
+        "nickname" => "vinny",
+        "email" => "pasta@pizza.vs",
+        "fullname" => "Vinny Vinesauce",
+        "bio" => "streamer",
+        "password" => "hiptofbees",
+        "confirm" => "hiptofbees",
+        "token" => invite.token
+      }
 
-    assert UserView.render("show.json", %{user: user}) ==
-             UserView.render("show.json", %{user: fetched_user})
+      {:ok, user} = TwitterAPI.register_user(data)
+
+      fetched_user = User.get_by_nickname("vinny")
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+
+      assert invite.used == true
+
+      assert UserView.render("show.json", %{user: user}) ==
+               UserView.render("show.json", %{user: fetched_user})
+    end
+
+    test "returns error on invalid token" do
+      data = %{
+        "nickname" => "GrimReaper",
+        "email" => "death@reapers.afterlife",
+        "fullname" => "Reaper Grim",
+        "bio" => "Your time has come",
+        "password" => "scythe",
+        "confirm" => "scythe",
+        "token" => "DudeLetMeInImAFairy"
+      }
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Invalid token"
+      refute User.get_by_nickname("GrimReaper")
+    end
+
+    test "returns error on expired token" do
+      {:ok, invite} = UserInviteToken.create_invite()
+      UserInviteToken.update_invite!(invite, used: true)
+
+      data = %{
+        "nickname" => "GrimReaper",
+        "email" => "death@reapers.afterlife",
+        "fullname" => "Reaper Grim",
+        "bio" => "Your time has come",
+        "password" => "scythe",
+        "confirm" => "scythe",
+        "token" => invite.token
+      }
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Expired token"
+      refute User.get_by_nickname("GrimReaper")
+    end
   end
 
-  @moduletag skip: "needs 'registrations_open: false' in config"
-  test "it returns an error if invalid token submitted" do
-    data = %{
-      "nickname" => "GrimReaper",
-      "email" => "death@reapers.afterlife",
-      "fullname" => "Reaper Grim",
-      "bio" => "Your time has come",
-      "password" => "scythe",
-      "confirm" => "scythe",
-      "token" => "DudeLetMeInImAFairy"
-    }
+  describe "registers with date limited token" do
+    setup do
+      setting = Pleroma.Config.get([:instance, :registrations_open])
+
+      if setting do
+        Pleroma.Config.put([:instance, :registrations_open], false)
+        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
+      end
+
+      data = %{
+        "nickname" => "vinny",
+        "email" => "pasta@pizza.vs",
+        "fullname" => "Vinny Vinesauce",
+        "bio" => "streamer",
+        "password" => "hiptofbees",
+        "confirm" => "hiptofbees"
+      }
+
+      check_fn = fn invite ->
+        data = Map.put(data, "token", invite.token)
+        {:ok, user} = TwitterAPI.register_user(data)
+        fetched_user = User.get_by_nickname("vinny")
+
+        assert UserView.render("show.json", %{user: user}) ==
+                 UserView.render("show.json", %{user: fetched_user})
+      end
+
+      {:ok, data: data, check_fn: check_fn}
+    end
+
+    test "returns user on success", %{check_fn: check_fn} do
+      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today()})
+
+      check_fn.(invite)
+
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+
+      refute invite.used
+    end
 
-    {:error, msg} = TwitterAPI.register_user(data)
+    test "returns user on token which expired tomorrow", %{check_fn: check_fn} do
+      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), 1)})
 
-    assert msg == "Invalid token"
-    refute User.get_by_nickname("GrimReaper")
+      check_fn.(invite)
+
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+
+      refute invite.used
+    end
+
+    test "returns an error on overdue date", %{data: data} do
+      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1)})
+
+      data = Map.put(data, "token", invite.token)
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Expired token"
+      refute User.get_by_nickname("vinny")
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+
+      refute invite.used
+    end
   end
 
-  @moduletag skip: "needs 'registrations_open: false' in config"
-  test "it returns an error if expired token submitted" do
-    {:ok, token} = UserInviteToken.create_token()
-    UserInviteToken.mark_as_used(token.token)
+  describe "registers with reusable token" do
+    setup do
+      setting = Pleroma.Config.get([:instance, :registrations_open])
 
-    data = %{
-      "nickname" => "GrimReaper",
-      "email" => "death@reapers.afterlife",
-      "fullname" => "Reaper Grim",
-      "bio" => "Your time has come",
-      "password" => "scythe",
-      "confirm" => "scythe",
-      "token" => token.token
-    }
+      if setting do
+        Pleroma.Config.put([:instance, :registrations_open], false)
+        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
+      end
+
+      :ok
+    end
+
+    test "returns user on success, after him registration fails" do
+      {:ok, invite} = UserInviteToken.create_invite(%{max_use: 100})
+
+      UserInviteToken.update_invite!(invite, uses: 99)
+
+      data = %{
+        "nickname" => "vinny",
+        "email" => "pasta@pizza.vs",
+        "fullname" => "Vinny Vinesauce",
+        "bio" => "streamer",
+        "password" => "hiptofbees",
+        "confirm" => "hiptofbees",
+        "token" => invite.token
+      }
+
+      {:ok, user} = TwitterAPI.register_user(data)
+      fetched_user = User.get_by_nickname("vinny")
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+
+      assert invite.used == true
+
+      assert UserView.render("show.json", %{user: user}) ==
+               UserView.render("show.json", %{user: fetched_user})
+
+      data = %{
+        "nickname" => "GrimReaper",
+        "email" => "death@reapers.afterlife",
+        "fullname" => "Reaper Grim",
+        "bio" => "Your time has come",
+        "password" => "scythe",
+        "confirm" => "scythe",
+        "token" => invite.token
+      }
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Expired token"
+      refute User.get_by_nickname("GrimReaper")
+    end
+  end
+
+  describe "registers with reusable date limited token" do
+    setup do
+      setting = Pleroma.Config.get([:instance, :registrations_open])
+
+      if setting do
+        Pleroma.Config.put([:instance, :registrations_open], false)
+        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
+      end
+
+      :ok
+    end
+
+    test "returns user on success" do
+      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100})
+
+      data = %{
+        "nickname" => "vinny",
+        "email" => "pasta@pizza.vs",
+        "fullname" => "Vinny Vinesauce",
+        "bio" => "streamer",
+        "password" => "hiptofbees",
+        "confirm" => "hiptofbees",
+        "token" => invite.token
+      }
+
+      {:ok, user} = TwitterAPI.register_user(data)
+      fetched_user = User.get_by_nickname("vinny")
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+
+      refute invite.used
+
+      assert UserView.render("show.json", %{user: user}) ==
+               UserView.render("show.json", %{user: fetched_user})
+    end
+
+    test "error after max uses" do
+      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100})
+
+      UserInviteToken.update_invite!(invite, uses: 99)
+
+      data = %{
+        "nickname" => "vinny",
+        "email" => "pasta@pizza.vs",
+        "fullname" => "Vinny Vinesauce",
+        "bio" => "streamer",
+        "password" => "hiptofbees",
+        "confirm" => "hiptofbees",
+        "token" => invite.token
+      }
+
+      {:ok, user} = TwitterAPI.register_user(data)
+      fetched_user = User.get_by_nickname("vinny")
+      invite = Repo.get_by(UserInviteToken, token: invite.token)
+      assert invite.used == true
+
+      assert UserView.render("show.json", %{user: user}) ==
+               UserView.render("show.json", %{user: fetched_user})
+
+      data = %{
+        "nickname" => "GrimReaper",
+        "email" => "death@reapers.afterlife",
+        "fullname" => "Reaper Grim",
+        "bio" => "Your time has come",
+        "password" => "scythe",
+        "confirm" => "scythe",
+        "token" => invite.token
+      }
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Expired token"
+      refute User.get_by_nickname("GrimReaper")
+    end
 
-    {:error, msg} = TwitterAPI.register_user(data)
+    test "returns error on overdue date" do
+      {:ok, invite} =
+        UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100})
 
-    assert msg == "Expired token"
-    refute User.get_by_nickname("GrimReaper")
+      data = %{
+        "nickname" => "GrimReaper",
+        "email" => "death@reapers.afterlife",
+        "fullname" => "Reaper Grim",
+        "bio" => "Your time has come",
+        "password" => "scythe",
+        "confirm" => "scythe",
+        "token" => invite.token
+      }
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Expired token"
+      refute User.get_by_nickname("GrimReaper")
+    end
+
+    test "returns error on with overdue date and after max" do
+      {:ok, invite} =
+        UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100})
+
+      UserInviteToken.update_invite!(invite, uses: 100)
+
+      data = %{
+        "nickname" => "GrimReaper",
+        "email" => "death@reapers.afterlife",
+        "fullname" => "Reaper Grim",
+        "bio" => "Your time has come",
+        "password" => "scythe",
+        "confirm" => "scythe",
+        "token" => invite.token
+      }
+
+      {:error, msg} = TwitterAPI.register_user(data)
+
+      assert msg == "Expired token"
+      refute User.get_by_nickname("GrimReaper")
+    end
   end
 
   test "it returns the error on registration problems" do