prop/test/microwaveprop/accounts/user_token_test.exs
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

300 lines
11 KiB
Elixir

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 byte_size(token) > 0
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 byte_size(encoded) > 0
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