From c0281e6c550109f8cdc65ee76bd5e3ceded803c7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 14:04:39 -0500 Subject: [PATCH] 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. --- test/microwaveprop/accounts/user_test.exs | 445 ++++++++++++++++++ .../accounts/user_token_test.exs | 300 ++++++++++++ 2 files changed, 745 insertions(+) create mode 100644 test/microwaveprop/accounts/user_test.exs create mode 100644 test/microwaveprop/accounts/user_token_test.exs diff --git a/test/microwaveprop/accounts/user_test.exs b/test/microwaveprop/accounts/user_test.exs new file mode 100644 index 00000000..7e60427a --- /dev/null +++ b/test/microwaveprop/accounts/user_test.exs @@ -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 diff --git a/test/microwaveprop/accounts/user_token_test.exs b/test/microwaveprop/accounts/user_token_test.exs new file mode 100644 index 00000000..23cf2631 --- /dev/null +++ b/test/microwaveprop/accounts/user_token_test.exs @@ -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