test: cover accounts user + user_token

80 characterization tests: registration/admin/email/password changesets,
password hashing, admin-flag grant logic, and the full token lifecycle
(session, magic link, confirm, change-email) including expiry boundaries
and cross-context rejection.
This commit is contained in:
Graham McIntire 2026-04-16 14:04:39 -05:00
parent 391a162bcd
commit c0281e6c55
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 745 additions and 0 deletions

View file

@ -0,0 +1,445 @@
defmodule Microwaveprop.Accounts.UserTest do
use Microwaveprop.DataCase, async: true
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Accounts.User
@valid_attrs %{
callsign: "W5ABC",
name: "Alice",
email: "alice@example.com",
password: "hunter2!!",
password_confirmation: "hunter2!!"
}
describe "admin_email/0" do
test "exposes the hard-coded admin email constant" do
assert User.admin_email() == "graham@mcintire.me"
end
end
describe "registration_changeset/3" do
test "valid attrs produce a valid changeset" do
changeset = User.registration_changeset(%User{}, @valid_attrs)
assert changeset.valid?
end
test "requires callsign, name, email, and password" do
changeset = User.registration_changeset(%User{}, %{})
errors = errors_on(changeset)
assert errors[:callsign]
assert errors[:name]
assert errors[:email]
assert errors[:password]
end
test "upcases callsign" do
attrs = Map.put(@valid_attrs, :callsign, "w5abc")
changeset = User.registration_changeset(%User{}, attrs)
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :callsign) == "W5ABC"
end
test "rejects callsigns shorter than 3 chars" do
attrs = Map.put(@valid_attrs, :callsign, "AB")
changeset = User.registration_changeset(%User{}, attrs)
assert "must be 3-10 letters and digits" in errors_on(changeset).callsign
end
test "rejects callsigns longer than 10 chars" do
attrs = Map.put(@valid_attrs, :callsign, "ABCDEFGHIJK")
changeset = User.registration_changeset(%User{}, attrs)
assert "must be 3-10 letters and digits" in errors_on(changeset).callsign
end
test "rejects callsigns with non-alphanumeric chars" do
attrs = Map.put(@valid_attrs, :callsign, "W5-AB")
changeset = User.registration_changeset(%User{}, attrs)
assert "must be 3-10 letters and digits" in errors_on(changeset).callsign
end
test "rejects name longer than 100 chars" do
attrs = Map.put(@valid_attrs, :name, String.duplicate("a", 101))
changeset = User.registration_changeset(%User{}, attrs)
assert errors_on(changeset).name
end
test "rejects email without @ sign" do
attrs = Map.put(@valid_attrs, :email, "not-an-email")
changeset = User.registration_changeset(%User{}, attrs)
assert "must have the @ sign and no spaces" in errors_on(changeset).email
end
test "rejects email with whitespace" do
attrs = Map.put(@valid_attrs, :email, "bad @example.com")
changeset = User.registration_changeset(%User{}, attrs)
assert "must have the @ sign and no spaces" in errors_on(changeset).email
end
test "rejects email longer than 160 chars" do
long_email = String.duplicate("a", 161) <> "@example.com"
attrs = Map.put(@valid_attrs, :email, long_email)
changeset = User.registration_changeset(%User{}, attrs)
assert errors_on(changeset).email
end
test "rejects password shorter than 8 chars" do
attrs = %{@valid_attrs | password: "short", password_confirmation: "short"}
changeset = User.registration_changeset(%User{}, attrs)
assert errors_on(changeset).password
end
test "rejects password longer than 72 chars" do
long_password = String.duplicate("a", 73)
attrs = %{@valid_attrs | password: long_password, password_confirmation: long_password}
changeset = User.registration_changeset(%User{}, attrs)
assert errors_on(changeset).password
end
test "requires password confirmation to match" do
attrs = %{@valid_attrs | password_confirmation: "different!"}
changeset = User.registration_changeset(%User{}, attrs)
assert "does not match password" in errors_on(changeset).password_confirmation
end
test "hashes password by default and clears virtual field" do
changeset = User.registration_changeset(%User{}, @valid_attrs)
assert changeset.valid?
hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
assert is_binary(hashed)
assert String.starts_with?(hashed, "$2b$") or String.starts_with?(hashed, "$2a$")
refute Ecto.Changeset.get_change(changeset, :password)
end
test "does not hash password when hash_password: false" do
changeset = User.registration_changeset(%User{}, @valid_attrs, hash_password: false)
assert changeset.valid?
refute Ecto.Changeset.get_change(changeset, :hashed_password)
assert Ecto.Changeset.get_change(changeset, :password) == @valid_attrs.password
end
test "final persisted struct contains hashed_password and no plaintext" do
{:ok, user} =
%User{}
|> User.registration_changeset(@valid_attrs)
|> Repo.insert()
assert is_binary(user.hashed_password)
assert user.password == nil
end
test "enforces email uniqueness via unsafe_validate_unique" do
{:ok, _first} =
%User{}
|> User.registration_changeset(@valid_attrs)
|> Repo.insert()
duplicate = User.registration_changeset(%User{}, %{@valid_attrs | callsign: "W5XYZ"})
refute duplicate.valid?
assert "has already been taken" in errors_on(duplicate).email
end
test "enforces callsign uniqueness via unsafe_validate_unique" do
{:ok, _first} =
%User{}
|> User.registration_changeset(@valid_attrs)
|> Repo.insert()
duplicate = User.registration_changeset(%User{}, %{@valid_attrs | email: "other@example.com"})
refute duplicate.valid?
assert "has already been taken" in errors_on(duplicate).callsign
end
test "surfaces unique constraint error on email at insert time (default opts)" do
{:ok, _first} =
%User{}
|> User.registration_changeset(@valid_attrs)
|> Repo.insert()
# With the default opts, unique_constraint/3 is attached so the DB
# violation comes back as a changeset error rather than raising.
changeset = User.registration_changeset(%User{}, %{@valid_attrs | callsign: "W5XYZ"})
# unsafe_validate_unique already flags it at changeset-build time.
assert "has already been taken" in errors_on(changeset).email
end
test "validate_unique: false skips callsign and email uniqueness checks" do
{:ok, _first} =
%User{}
|> User.registration_changeset(@valid_attrs)
|> Repo.insert()
changeset = User.registration_changeset(%User{}, @valid_attrs, validate_unique: false)
# Uniqueness is not pre-validated, so the changeset is otherwise valid.
assert changeset.valid?
end
test "grants admin when email matches the admin_email constant (case-insensitive)" do
attrs = Map.put(@valid_attrs, :email, "GRAHAM@MCINTIRE.ME")
changeset = User.registration_changeset(%User{}, attrs)
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :is_admin) == true
end
test "grants admin for exact admin email match" do
attrs = Map.put(@valid_attrs, :email, "graham@mcintire.me")
changeset = User.registration_changeset(%User{}, attrs)
assert Ecto.Changeset.get_change(changeset, :is_admin) == true
end
test "does not grant admin for other emails" do
changeset = User.registration_changeset(%User{}, @valid_attrs)
refute Ecto.Changeset.get_change(changeset, :is_admin)
end
test "does not set is_admin when email is missing" do
changeset = User.registration_changeset(%User{}, Map.delete(@valid_attrs, :email))
refute Ecto.Changeset.get_change(changeset, :is_admin)
end
end
describe "admin_changeset/2" do
setup do
{:ok, user} = Repo.insert(User.registration_changeset(%User{}, @valid_attrs))
%{user: user}
end
test "accepts valid updates and casts is_admin", %{user: user} do
changeset =
User.admin_changeset(user, %{
callsign: "K5NEW",
name: "New Name",
email: "new@example.com",
is_admin: true
})
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :is_admin) == true
assert Ecto.Changeset.get_change(changeset, :callsign) == "K5NEW"
end
test "trims and upcases callsign", %{user: user} do
changeset = User.admin_changeset(user, %{callsign: " k5abc "})
assert Ecto.Changeset.get_change(changeset, :callsign) == "K5ABC"
end
test "requires callsign, name, and email", %{user: user} do
changeset =
User.admin_changeset(user, %{callsign: nil, name: nil, email: nil})
errors = errors_on(changeset)
assert errors[:callsign]
assert errors[:name]
assert errors[:email]
end
test "rejects invalid callsign format", %{user: user} do
changeset = User.admin_changeset(user, %{callsign: "bad-call"})
assert "must be 3-10 letters and digits" in errors_on(changeset).callsign
end
test "rejects invalid email", %{user: user} do
changeset = User.admin_changeset(user, %{email: "bad email"})
assert "must have the @ sign and no spaces" in errors_on(changeset).email
end
test "does not cast password", %{user: user} do
changeset = User.admin_changeset(user, %{password: "something1234"})
refute Ecto.Changeset.get_change(changeset, :password)
refute Ecto.Changeset.get_change(changeset, :hashed_password)
end
test "enforces email uniqueness", %{user: user} do
{:ok, _other} =
Repo.insert(
User.registration_changeset(%User{}, %{
@valid_attrs
| callsign: "K5OTH",
email: "other@example.com"
})
)
changeset = User.admin_changeset(user, %{email: "other@example.com"})
refute changeset.valid?
assert "has already been taken" in errors_on(changeset).email
end
test "enforces callsign uniqueness", %{user: user} do
{:ok, _other} =
Repo.insert(
User.registration_changeset(%User{}, %{
@valid_attrs
| callsign: "K5OTH",
email: "other@example.com"
})
)
changeset = User.admin_changeset(user, %{callsign: "K5OTH"})
refute changeset.valid?
assert "has already been taken" in errors_on(changeset).callsign
end
end
describe "email_changeset/3" do
setup do
{:ok, user} = Repo.insert(User.registration_changeset(%User{}, @valid_attrs))
%{user: user}
end
test "accepts a new valid email", %{user: user} do
changeset = User.email_changeset(user, %{email: "new-address@example.com"})
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :email) == "new-address@example.com"
end
test "requires email", %{user: user} do
changeset = User.email_changeset(user, %{email: nil})
assert errors_on(changeset).email
end
test "rejects malformed email", %{user: user} do
changeset = User.email_changeset(user, %{email: "no-at-sign"})
assert "must have the @ sign and no spaces" in errors_on(changeset).email
end
test "rejects email longer than 160 chars", %{user: user} do
long = String.duplicate("a", 161) <> "@example.com"
changeset = User.email_changeset(user, %{email: long})
assert errors_on(changeset).email
end
test "errors if email did not change", %{user: user} do
changeset = User.email_changeset(user, %{email: user.email})
assert "did not change" in errors_on(changeset).email
end
test "enforces email uniqueness against existing users", %{user: user} do
{:ok, _other} =
Repo.insert(
User.registration_changeset(%User{}, %{
@valid_attrs
| callsign: "K5OTH",
email: "taken@example.com"
})
)
changeset = User.email_changeset(user, %{email: "taken@example.com"})
refute changeset.valid?
assert "has already been taken" in errors_on(changeset).email
end
test "validate_unique: false skips the uniqueness check", %{user: user} do
{:ok, _other} =
Repo.insert(
User.registration_changeset(%User{}, %{
@valid_attrs
| callsign: "K5OTH",
email: "taken@example.com"
})
)
changeset = User.email_changeset(user, %{email: "taken@example.com"}, validate_unique: false)
assert changeset.valid?
end
end
describe "password_changeset/3" do
setup do
{:ok, user} = Repo.insert(User.registration_changeset(%User{}, @valid_attrs))
%{user: user}
end
test "accepts a valid new password and hashes it by default", %{user: user} do
changeset =
User.password_changeset(user, %{
password: "new-password-123",
password_confirmation: "new-password-123"
})
assert changeset.valid?
hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
assert is_binary(hashed)
refute Ecto.Changeset.get_change(changeset, :password)
end
test "requires the password", %{user: user} do
changeset = User.password_changeset(user, %{})
assert errors_on(changeset).password
end
test "rejects password shorter than 8 chars", %{user: user} do
changeset =
User.password_changeset(user, %{password: "short", password_confirmation: "short"})
assert errors_on(changeset).password
end
test "rejects password longer than 72 chars", %{user: user} do
pw = String.duplicate("a", 73)
changeset = User.password_changeset(user, %{password: pw, password_confirmation: pw})
assert errors_on(changeset).password
end
test "requires password_confirmation to match", %{user: user} do
changeset =
User.password_changeset(user, %{
password: "new-password-123",
password_confirmation: "other-password-123"
})
assert "does not match password" in errors_on(changeset).password_confirmation
end
test "hash_password: false keeps plaintext and skips hashing", %{user: user} do
changeset =
User.password_changeset(
user,
%{password: "new-password-123", password_confirmation: "new-password-123"},
hash_password: false
)
assert changeset.valid?
refute Ecto.Changeset.get_change(changeset, :hashed_password)
assert Ecto.Changeset.get_change(changeset, :password) == "new-password-123"
end
end
describe "confirm_changeset/1" do
test "sets confirmed_at to the current UTC time (truncated to seconds)" do
user = %User{}
changeset = User.confirm_changeset(user)
confirmed_at = Ecto.Changeset.get_change(changeset, :confirmed_at)
assert %DateTime{} = confirmed_at
assert confirmed_at.microsecond == {0, 0}
assert DateTime.diff(DateTime.utc_now(), confirmed_at, :second) in 0..5
end
end
describe "valid_password?/2" do
test "returns true when the password matches the hash" do
user = user_fixture()
assert User.valid_password?(user, valid_user_password())
end
test "returns false for an incorrect password" do
user = user_fixture()
refute User.valid_password?(user, "wrong password")
end
test "returns false when hashed_password is nil" do
refute User.valid_password?(%User{hashed_password: nil}, "anything")
end
test "returns false for empty password input" do
user = user_fixture()
refute User.valid_password?(user, "")
end
test "returns false for a nil user-like value" do
refute User.valid_password?(nil, "anything")
end
end
end

View file

@ -0,0 +1,300 @@
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