diff --git a/lib/perseus/accounts/user.ex b/lib/perseus/accounts/user.ex index 97d4ab3..0028aa9 100644 --- a/lib/perseus/accounts/user.ex +++ b/lib/perseus/accounts/user.ex @@ -16,7 +16,7 @@ defmodule Perseus.Accounts.User do user |> cast(attrs, [:first_name, :last_name, :email]) |> validate_format(:email, ~r/@*\.[A-Za-z.]{2,}/, message: "Must be a valid email address") - |> unique_constraint(:email, name: :unique_email) + |> unique_constraint(:email) |> validate_required([:first_name, :last_name, :email]) end end diff --git a/lib/perseus/auth.ex b/lib/perseus/auth.ex index 75de505..3afb7ae 100644 --- a/lib/perseus/auth.ex +++ b/lib/perseus/auth.ex @@ -13,11 +13,9 @@ defmodule Perseus.Auth do with {:ok, _} <- Accounts.get_user_by_email(email), {:ok, token} <- generate_login_token(email), encoded_token <- BinaryUtils.encode(token) do - # If `url_fun` is provided, deliver the login link via email if url_fun do UserNotifier.deliver_login_link(email, url_fun.(encoded_token)) else - # Otherwise, just return the encoded token {:ok, encoded_token} end else @@ -25,26 +23,16 @@ defmodule Perseus.Auth do end end - def send_signup_email(email, url_fun) do - with {:ok, token} <- generate_login_token(email), - encoded_token <- BinaryUtils.encode(token), - {:ok, _} <- UserNotifier.deliver_signup_link(email, url_fun.(encoded_token)) do - end - end - - def login_user_by_login_token(token) do - with {:ok, email} <- find_email(:magic_link, token), - {:ok, session_token} <- generate_signup_token(email) do - {:ok, session_token} + def send_signup_email(email, url_fun \\ nil) do + with {:ok, token} <- generate_signup_token(email), + encoded_token <- BinaryUtils.encode(token) do + if url_fun do + UserNotifier.deliver_signup_link(email, url_fun.(encoded_token)) + else + {:ok, encoded_token} + end else - {:error, :expired_token} -> - {:error, "Expired token"} - - {:error, :not_found} -> - {:error, "Not found"} - - _ -> - {:error, "Token store Error"} + _ -> :error end end diff --git a/lib/perseus/auth/token_store.ex b/lib/perseus/auth/token_store.ex index 3677acf..5c28b9d 100644 --- a/lib/perseus/auth/token_store.ex +++ b/lib/perseus/auth/token_store.ex @@ -44,7 +44,7 @@ defmodule Perseus.Auth.TokenStore do end def validate_signup_token(token) do - validate_token(token, @signup_table, true) + validate_token(token, @signup_table) end def validate_session_token(token) do diff --git a/lib/perseus_web/plugs/authenticate_user.ex b/lib/perseus_web/plugs/authenticate_user.ex index a681c90..d9b09e8 100644 --- a/lib/perseus_web/plugs/authenticate_user.ex +++ b/lib/perseus_web/plugs/authenticate_user.ex @@ -12,6 +12,7 @@ defmodule PerseusWeb.Plugs.AuthenticateUser do defp build_context(conn) do case get_auth_token(conn) do + {:ok, nil, nil} -> %{} {:ok, token_type, token} -> build_context_from_token(token, token_type) _ -> %{error: "Authorization header invalid"} end @@ -42,6 +43,7 @@ defmodule PerseusWeb.Plugs.AuthenticateUser do defp get_auth_token(conn) do case get_req_header(conn, "authorization") do + [] -> {:ok, nil, nil} ["Bearer " <> token] -> decode_token(:session, token) ["MagicLink " <> token] -> decode_token(:magic_link, token) ["Signup " <> token] -> decode_token(:signup, token) diff --git a/lib/perseus_web/resolvers/auth.ex b/lib/perseus_web/resolvers/auth.ex index ef7161e..2eef3fd 100644 --- a/lib/perseus_web/resolvers/auth.ex +++ b/lib/perseus_web/resolvers/auth.ex @@ -12,8 +12,14 @@ defmodule PerseusWeb.Resolvers.Auth do end def send_signup_link(_parent, %{email: email}, _resolution) do - Auth.send_signup_email(email, &"localhost:3000/signup/#{&1}") - {:ok, true} + case Accounts.get_user_by_email(email) do + {:error, _} -> + Auth.send_signup_email(email, &"localhost:3000/signup/#{&1}") + {:ok, true} + + {:ok, _user} -> + {:ok, true} + end end def login_user(_parent, _args, %{context: %{email: email}}) do diff --git a/lib/perseus_web/schema/mutations/auth_mutations.ex b/lib/perseus_web/schema/mutations/auth_mutations.ex index 83e3fcc..c290dba 100644 --- a/lib/perseus_web/schema/mutations/auth_mutations.ex +++ b/lib/perseus_web/schema/mutations/auth_mutations.ex @@ -24,7 +24,7 @@ defmodule PerseusWeb.Schema.Mutations.AuthMutations do field :sign_up, :sign_up_response do arg :user, :user_input - middleware PerseusWeb.Middleware.RequireAuth, :magic_link + middleware PerseusWeb.Middleware.RequireAuth, :signup resolve &Resolvers.Auth.signup_user/3 end diff --git a/test/perseus_web/plugs/authenticate_user_test.exs b/test/perseus_web/plugs/authenticate_user_test.exs new file mode 100644 index 0000000..6c4e4cc --- /dev/null +++ b/test/perseus_web/plugs/authenticate_user_test.exs @@ -0,0 +1,101 @@ +defmodule PerseusWeb.Plugs.AuthenticateUserTest do + use PerseusWeb.ConnCase, async: true + import Perseus.AccountsFixtures + + alias Perseus.Utils.BinaryUtils + alias PerseusWeb.Plugs.AuthenticateUser + + describe "session token" do + setup do + # Here you can mock Auth or BinaryUtils as needed + user = user_fixture() + {:ok, valid_token} = Perseus.Auth.login_user_by_email(user.email) + %{user: user, token: BinaryUtils.encode(valid_token)} + end + + test "adds context with user for valid session token", %{conn: conn, token: token, user: user} do + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> AuthenticateUser.call(%{}) + + # Verify that the context was added correctly + context = conn.private.absinthe.context + assert context[:user] == user + assert context[:token_type] == :session + end + + test "returns error for invalid authorization header", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer invalidtoken}") + |> AuthenticateUser.call(%{}) + + # Check that an error is returned + context = conn.private.absinthe.context + assert context[:error] == "Authorization header invalid" + end + end + + describe "login token" do + setup do + user = user_fixture() + {:ok, token} = Perseus.Auth.send_login_email(user.email) + %{user: user, token: token} + end + + test "adds context with email for valid login token", %{conn: conn, user: user, token: token} do + conn = + conn + |> put_req_header("authorization", "MagicLink #{token}") + |> AuthenticateUser.call(%{}) + + # Verify that the context was added correctly + context = conn.private.absinthe.context + assert context[:email] == user.email + assert context[:token_type] == :magic_link + end + end + + describe "signup token" do + setup do + email = unique_user_email() + {:ok, token} = Perseus.Auth.send_signup_email(email) + %{email: email, token: token} + end + + test "adds context with email for valid signup token", %{ + conn: conn, + email: email, + token: token + } do + conn = + conn + |> put_req_header("authorization", "Signup #{token}") + |> AuthenticateUser.call(%{}) + + # Verify that the context was added correctly + context = conn.private.absinthe.context + assert context[:email] == email + assert context[:token_type] == :signup + end + end + + test "returns normal conn when no authorization header is present", %{conn: conn} do + conn = AuthenticateUser.call(conn, %{}) + + context = conn.private.absinthe.context + assert context == %{} + end + + test "returns error for invalid authorization header", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Invalid token}") + |> AuthenticateUser.call(%{}) + + # Check that an error is returned + context = conn.private.absinthe.context + assert context[:error] == "Authorization header invalid" + end +end diff --git a/test/perseus_web/resolvers/auth_test.exs b/test/perseus_web/resolvers/auth_test.exs index 3984ee7..4a2c390 100644 --- a/test/perseus_web/resolvers/auth_test.exs +++ b/test/perseus_web/resolvers/auth_test.exs @@ -1,5 +1,5 @@ defmodule PerseusWeb.Resolvers.AuthTest do - use Perseus.DataCase + use Perseus.DataCase, async: true alias Perseus.Auth alias PerseusWeb.Resolvers diff --git a/test/perseus_web/schema/mutations/auth_mutations_test.exs b/test/perseus_web/schema/mutations/auth_mutations_test.exs index 908d193..9e270e1 100644 --- a/test/perseus_web/schema/mutations/auth_mutations_test.exs +++ b/test/perseus_web/schema/mutations/auth_mutations_test.exs @@ -6,27 +6,27 @@ defmodule PerseusWeb.Schema.Mutations.AuthMutationsTest do import Perseus.AccountsFixtures # Define the query that you will be testing - @login_query """ - mutation startSession { - logIn { - sessionToken - } - } - """ describe "login mutation" do setup do + query = """ + mutation startSession { + logIn { + sessionToken + } + } + """ + user = user_fixture() {:ok, token} = Auth.send_login_email(user.email) - %{user: user, token: token} + %{user: user, token: token, query: query} end - test "should login when email is found", %{conn: conn, user: user, token: token} do + test "should login when email is found", %{conn: conn, user: user, token: token, query: query} do conn = conn |> put_req_header("authorization", "MagicLink " <> token) - |> put_req_header("x-custom-header", "custom-value") - |> post("/api/graphql", %{query: @login_query}) + |> post("/api/graphql", %{query: query}) assert json_response(conn, 200) response_data = json_response(conn, 200)["data"] @@ -40,5 +40,107 @@ defmodule PerseusWeb.Schema.Mutations.AuthMutationsTest do {:ok, decoded_session_token} = BinaryUtils.decode(session_token) assert {:ok, ^user} = Auth.find_user(decoded_session_token) end + + test "should not resolve if token is misconfigured", %{conn: conn, query: query} do + conn = + conn + |> put_req_header("authorization", "MagicLink " <> "invalidtoken") + |> post("/api/graphql", %{query: query}) + + assert json_response(conn, 200) + errors = json_response(conn, 200)["errors"] + + assert [%{"code" => "UNAUTHENTICATED", "message" => "not_found"}] = errors + end + + test "should only allow magic link tokens", %{conn: conn, query: query, token: token} do + conn = + conn + |> put_req_header("authorization", "Bearer " <> token) + |> post("/api/graphql", %{query: query}) + + assert json_response(conn, 200) + errors = json_response(conn, 200)["errors"] + + assert [%{"code" => "UNAUTHENTICATED", "message" => "not_found"}] = errors + end + end + + describe "sendLoginLink mutation" do + import Swoosh.TestAssertions + + setup do + query = """ + mutation($email: String!) { + sendLoginLink(email: $email) + } + """ + + user = user_fixture() + %{query: query, user: user} + end + + test "should send an email when email is found and return true", %{ + conn: conn, + query: query, + user: user + } do + variables = %{"email" => user.email} + conn = post(conn, "/api/graphql", %{query: query, variables: variables}) + + assert json_response(conn, 200) + response_data = json_response(conn, 200)["data"] + + assert %{"sendLoginLink" => true} = response_data + + assert_email_sent(fn email -> + [{_, delivered_to}] = email.to + assert delivered_to == user.email + end) + end + + test "should not send an email when email is found and still return true", %{ + conn: conn, + query: query + } do + variables = %{"email" => "invalid@example.com"} + conn = post(conn, "/api/graphql", %{query: query, variables: variables}) + + assert json_response(conn, 200) + response_data = json_response(conn, 200)["data"] + + assert %{"sendLoginLink" => true} = response_data + assert_no_email_sent() + end + end + + describe "sendSignupLink mutation" do + import Swoosh.TestAssertions + + setup do + query = """ + mutation ($email: String!) { + sendSignupLink(email: $email) + } + """ + + %{query: query} + end + + test "should send an email to sign up", %{conn: conn, query: query} do + email_address = "new.email@example.com" + variables = %{"email" => email_address} + conn = post(conn, "/api/graphql", %{query: query, variables: variables}) + + assert json_response(conn, 200) + response_data = json_response(conn, 200)["data"] + + assert %{"sendSignupLink" => true} = response_data + + assert_email_sent(fn email -> + [{_, delivered_to}] = email.to + assert delivered_to == email_address + end) + end end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index e99825a..13333ae 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -4,6 +4,8 @@ defmodule Perseus.AccountsFixtures do entities via the `Perseus.Accounts` context. """ + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + @doc """ Generate a user. """ @@ -18,7 +20,7 @@ defmodule Perseus.AccountsFixtures do def valid_user_attributes(attr \\ %{}) do Enum.into(attr, %{ - email: "someemail@example.com", + email: unique_user_email(), first_name: "some first_name", last_name: "some last_name", verified: true