- 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
300 lines
11 KiB
Elixir
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
|