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:
parent
391a162bcd
commit
c0281e6c55
2 changed files with 745 additions and 0 deletions
445
test/microwaveprop/accounts/user_test.exs
Normal file
445
test/microwaveprop/accounts/user_test.exs
Normal 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
|
||||
300
test/microwaveprop/accounts/user_token_test.exs
Normal file
300
test/microwaveprop/accounts/user_token_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue