defmodule Microwaveprop.Accounts.UserTokenTest do use Microwaveprop.DataCase, async: true import Microwaveprop.AccountsFixtures alias Microwaveprop.Accounts.UserToken @hash_algorithm :sha256 @rand_size 32 describe "build_session_token/1" do test "returns the raw token and a matching UserToken struct" do user = user_fixture() {token, user_token} = UserToken.build_session_token(user) assert is_binary(token) assert byte_size(token) == @rand_size assert %UserToken{} = user_token assert user_token.token == token assert user_token.context == "session" assert user_token.user_id == user.id end test "uses the user's authenticated_at when present" do user = user_fixture() dt = DateTime.add(DateTime.utc_now(:second), -30, :minute) user = %{user | authenticated_at: dt} {_token, user_token} = UserToken.build_session_token(user) assert DateTime.compare(user_token.authenticated_at, dt) == :eq end test "falls back to current UTC time when authenticated_at is nil" do user = user_fixture() {_token, user_token} = UserToken.build_session_token(user) assert %DateTime{} = user_token.authenticated_at assert DateTime.diff(DateTime.utc_now(), user_token.authenticated_at, :second) in 0..5 end test "emits a different token on each call" do user = user_fixture() {token_a, _} = UserToken.build_session_token(user) {token_b, _} = UserToken.build_session_token(user) refute token_a == token_b end end describe "verify_session_token_query/1" do setup do user = user_fixture() {token, user_token} = UserToken.build_session_token(user) {:ok, persisted} = Repo.insert(user_token) %{user: user, token: token, user_token: persisted} end test "returns the user and inserted_at for a valid session token", %{ user: user, token: token, user_token: persisted } do {:ok, query} = UserToken.verify_session_token_query(token) assert {fetched_user, inserted_at} = Repo.one(query) assert fetched_user.id == user.id assert fetched_user.authenticated_at == persisted.authenticated_at assert inserted_at == persisted.inserted_at end test "returns nil for an unknown token" do {:ok, query} = UserToken.verify_session_token_query(:crypto.strong_rand_bytes(@rand_size)) assert Repo.one(query) == nil end test "returns nil when the token has expired", %{token: token} do # 14 days is the session validity window. offset_user_token(token, -15, :day) {:ok, query} = UserToken.verify_session_token_query(token) assert Repo.one(query) == nil end test "still returns the user exactly at the validity boundary", %{token: token, user: user} do # 13 days ago is still within the 14-day validity window. offset_user_token(token, -13, :day) {:ok, query} = UserToken.verify_session_token_query(token) assert {fetched, _inserted_at} = Repo.one(query) assert fetched.id == user.id end test "does not match against a non-session context", %{user: user} do {_raw, email_token} = UserToken.build_email_token(user, "confirm") {:ok, _} = Repo.insert(email_token) # The session verifier only matches the "session" context. {:ok, query} = UserToken.verify_session_token_query(email_token.token) assert Repo.one(query) == nil end end describe "build_email_token/2" do test "returns a url-safe encoded token and a hashed stored token" do user = user_fixture() {encoded, user_token} = UserToken.build_email_token(user, "confirm") assert is_binary(encoded) assert {:ok, decoded} = Base.url_decode64(encoded, padding: false) assert byte_size(decoded) == @rand_size assert user_token.context == "confirm" assert user_token.sent_to == user.email assert user_token.user_id == user.id assert user_token.token == :crypto.hash(@hash_algorithm, decoded) refute user_token.token == decoded end test "different contexts produce different stored rows" do user = user_fixture() {_a, tok_a} = UserToken.build_email_token(user, "confirm") {_b, tok_b} = UserToken.build_email_token(user, "login") assert tok_a.context == "confirm" assert tok_b.context == "login" refute tok_a.token == tok_b.token end end describe "verify_magic_link_token_query/1" do setup do user = user_fixture() {encoded, user_token} = UserToken.build_email_token(user, "login") {:ok, persisted} = Repo.insert(user_token) %{user: user, encoded: encoded, raw_token: persisted.token} end test "returns the user and token record for a valid magic-link token", %{ user: user, encoded: encoded } do {:ok, query} = UserToken.verify_magic_link_token_query(encoded) assert {fetched_user, fetched_token} = Repo.one(query) assert fetched_user.id == user.id assert fetched_token.context == "login" end test "returns :error for a malformed (non-base64) token" do assert UserToken.verify_magic_link_token_query("not base64!!") == :error end test "returns nil when the token is not in the database" do foreign = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false) {:ok, query} = UserToken.verify_magic_link_token_query(foreign) assert Repo.one(query) == nil end test "returns nil once the token has expired", %{encoded: encoded, raw_token: raw} do # Magic link validity is 15 minutes. Repo.update_all( from(t in UserToken, where: t.token == ^raw), set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -16, :minute)] ) {:ok, query} = UserToken.verify_magic_link_token_query(encoded) assert Repo.one(query) == nil end test "returns nil when the user's email no longer matches sent_to", %{ user: user, encoded: encoded } do # sent_to == user.email is asserted by the query; changing the user's # email invalidates the token. {:ok, _user} = user |> Ecto.Changeset.change(email: "changed-#{System.unique_integer([:positive])}@example.com") |> Repo.update() {:ok, query} = UserToken.verify_magic_link_token_query(encoded) assert Repo.one(query) == nil end end describe "verify_confirm_token_query/1" do setup do user = user_fixture() {encoded, user_token} = UserToken.build_email_token(user, "confirm") {:ok, persisted} = Repo.insert(user_token) %{user: user, encoded: encoded, raw_token: persisted.token} end test "returns user and token for a valid confirm token", %{user: user, encoded: encoded} do {:ok, query} = UserToken.verify_confirm_token_query(encoded) assert {fetched, token} = Repo.one(query) assert fetched.id == user.id assert token.context == "confirm" end test "returns :error for a malformed token" do assert UserToken.verify_confirm_token_query("***not-base64***") == :error end test "returns nil for an unknown token" do other = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false) {:ok, query} = UserToken.verify_confirm_token_query(other) assert Repo.one(query) == nil end test "returns nil when the token is older than 1 day", %{encoded: encoded, raw_token: raw} do Repo.update_all( from(t in UserToken, where: t.token == ^raw), set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -2, :day)] ) {:ok, query} = UserToken.verify_confirm_token_query(encoded) assert Repo.one(query) == nil end test "returns nil when the user's email has changed", %{user: user, encoded: encoded} do {:ok, _user} = user |> Ecto.Changeset.change(email: "new-#{System.unique_integer([:positive])}@example.com") |> Repo.update() {:ok, query} = UserToken.verify_confirm_token_query(encoded) assert Repo.one(query) == nil end test "does not match a token stored under a different context", %{user: user} do # Build a "login" token, encode it, and try to verify against "confirm". {encoded, user_token} = UserToken.build_email_token(user, "login") {:ok, _} = Repo.insert(user_token) {:ok, query} = UserToken.verify_confirm_token_query(encoded) assert Repo.one(query) == nil end end describe "verify_change_email_token_query/2" do setup do user = user_fixture() context = "change:#{user.email}" {encoded, user_token} = UserToken.build_email_token(user, context) {:ok, persisted} = Repo.insert(user_token) %{user: user, encoded: encoded, context: context, raw_token: persisted.token} end test "returns the user_token for a valid change-email token", %{ encoded: encoded, context: context, user: user } do {:ok, query} = UserToken.verify_change_email_token_query(encoded, context) assert %UserToken{} = token = Repo.one(query) assert token.context == context assert token.sent_to == user.email assert token.user_id == user.id end test "rejects a context that does not start with \"change:\"" do encoded = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false) assert_raise FunctionClauseError, fn -> UserToken.verify_change_email_token_query(encoded, "confirm") end end test "returns :error for a malformed token", %{context: context} do assert UserToken.verify_change_email_token_query("***", context) == :error end test "returns nil for an unknown token", %{context: context} do foreign = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false) {:ok, query} = UserToken.verify_change_email_token_query(foreign, context) assert Repo.one(query) == nil end test "returns nil once the token is older than 7 days", %{ encoded: encoded, context: context, raw_token: raw } do Repo.update_all( from(t in UserToken, where: t.token == ^raw), set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -8, :day)] ) {:ok, query} = UserToken.verify_change_email_token_query(encoded, context) assert Repo.one(query) == nil end test "returns nil when the stored row was written under a different context", %{ user: user } do {encoded, user_token} = UserToken.build_email_token(user, "change:stale@example.com") {:ok, _} = Repo.insert(user_token) {:ok, query} = UserToken.verify_change_email_token_query(encoded, "change:#{user.email}") assert Repo.one(query) == nil end end end