prop/test/microwaveprop/accounts_test.exs
Graham McIntire fc245367e3
User profiles at /u/:callsign, flexible band input, assorted UX cleanup
Profile page:
  * New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
    page showing a user's contacts and beacons. Resolves case-
    insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
    callsigns redirect to /. Uses daisyUI card / stats / table
    components with an avatar-placeholder initial and hero icons.
  * Accounts.get_user_by_callsign/1 (case-insensitive) plus
    Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
    back the page. Beacons list includes both approved and pending so
    owners see their drafts.
  * The top nav bar and the three LiveView sidebars (MapLive,
    WeatherMapLive, ContactMapLive) now render the logged-in callsign
    as a navigate link to /u/:callsign instead of a static label.
  * Nine new tests cover the lookup, the LiveView render, and the
    ownership-scoped queries.

Flexible band input:
  * New Microwaveprop.Radio.BandResolver module converts any of:
    ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
    insensitive), numeric frequency strings ("903.100", "10368.000"),
    and canonical MHz integers into the one of the site's known bands.
    Returns the nearest allowed band for numeric inputs >= 900 MHz,
    nil otherwise.
  * 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
    AdifImport.@allowed_bands, and the BandResolver list so "33cm"
    round-trips end-to-end.
  * AdifImport and CsvImport now delegate band resolution to
    BandResolver, and Radio.create_contact/2 normalizes the :band attr
    on the way in so the manual form and any API callers benefit too.
    CsvImport's "invalid band" tests previously used 99999 MHz which
    the new resolver snaps to the nearest allowed band; swapped to
    "notaband" which is truly unresolvable.

Contacts and beacons list UX:
  * Remove the "Submitted" column from /contacts — it duplicated info
    already visible on the detail page and was pushing the real
    columns off narrow viewports. submitted_cell/1 and its three
    column-specific tests go with it.
  * Hide the Lat / Lon columns from /beacons — six decimal places of
    coordinates weren't useful next to the grid square and took a
    disproportionate amount of row width.
2026-04-12 16:13:08 -05:00

477 lines
15 KiB
Elixir

defmodule Microwaveprop.AccountsTest do
use Microwaveprop.DataCase
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.User
alias Microwaveprop.Accounts.UserToken
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
end
end
describe "get_user_by_callsign/1" do
test "returns the user for an exact callsign match" do
%{id: id} = user = user_fixture(%{callsign: "W5ISP"})
assert %User{id: ^id} = Accounts.get_user_by_callsign(user.callsign)
end
test "is case-insensitive so /u/w5isp and /u/W5ISP both resolve" do
%{id: id} = user_fixture(%{callsign: "W5ISP"})
assert %User{id: ^id} = Accounts.get_user_by_callsign("w5isp")
end
test "returns nil when no user matches" do
refute Accounts.get_user_by_callsign("NO1SUCH")
end
test "returns nil for nil or empty callsign" do
refute Accounts.get_user_by_callsign(nil)
refute Accounts.get_user_by_callsign("")
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the user if the password is not valid" do
user = user_fixture()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!("11111111-1111-1111-1111-111111111111")
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user!(user.id)
end
end
describe "register_user/1" do
test "requires callsign, name, email, and password" do
{:error, changeset} = Accounts.register_user(%{})
assert %{
callsign: ["can't be blank"],
name: ["can't be blank"],
email: ["can't be blank"],
password: ["can't be blank"]
} = errors_on(changeset)
end
test "validates email format" do
{:error, changeset} =
Accounts.register_user(valid_user_attributes(email: "not valid"))
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum email length for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.register_user(valid_user_attributes(email: too_long))
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} =
Accounts.register_user(valid_user_attributes(email: email))
assert "has already been taken" in errors_on(changeset).email
# Uppercased should also collide (citext column).
{:error, changeset} =
Accounts.register_user(valid_user_attributes(email: String.upcase(email)))
assert "has already been taken" in errors_on(changeset).email
end
test "validates callsign format" do
{:error, changeset} =
Accounts.register_user(valid_user_attributes(callsign: "no"))
assert "must be 3-10 letters and digits" in errors_on(changeset).callsign
end
test "normalizes callsign to uppercase and enforces uniqueness" do
%{callsign: callsign} = user_fixture(callsign: "w5test")
assert callsign == "W5TEST"
{:error, changeset} =
Accounts.register_user(valid_user_attributes(callsign: "w5test"))
assert "has already been taken" in errors_on(changeset).callsign
end
test "validates password confirmation matches" do
{:error, changeset} =
Accounts.register_user(valid_user_attributes(password: "hello world!", password_confirmation: "mismatch!!!!"))
assert "does not match password" in errors_on(changeset).password_confirmation
end
test "validates password length" do
{:error, changeset} =
Accounts.register_user(valid_user_attributes(password: "short"))
assert "should be at least 8 character(s)" in errors_on(changeset).password
end
test "registers a valid user but leaves them unconfirmed" do
attrs = valid_user_attributes()
{:ok, user} = Accounts.register_user(attrs)
assert user.email == attrs.email
assert user.callsign == String.upcase(attrs.callsign)
assert user.name == attrs.name
assert is_binary(user.hashed_password)
assert is_nil(user.password)
assert is_nil(user.confirmed_at)
refute user.is_admin
end
test "grants is_admin to the configured admin email" do
{:ok, user} =
Accounts.register_user(valid_user_attributes(email: User.admin_email()))
assert user.is_admin
end
test "grants is_admin regardless of email case" do
upper = String.upcase(User.admin_email())
{:ok, user} = Accounts.register_user(valid_user_attributes(email: upper))
assert user.is_admin
end
test "does not grant is_admin to other emails" do
{:ok, user} = Accounts.register_user(valid_user_attributes())
refute user.is_admin
end
end
describe "deliver_user_confirmation_instructions/2" do
test "sends token through notification" do
user = unconfirmed_user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "confirm"
end
test "returns {:error, :already_confirmed} when the user is already confirmed" do
user = user_fixture()
assert {:error, :already_confirmed} =
Accounts.deliver_user_confirmation_instructions(user, & &1)
end
end
describe "confirm_user_by_token/1" do
setup do
user = unconfirmed_user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
%{user: user, token: token}
end
test "confirms the user with a valid token", %{user: user, token: token} do
assert {:ok, {confirmed_user, _}} = Accounts.confirm_user_by_token(token)
assert confirmed_user.id == user.id
assert confirmed_user.confirmed_at
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not confirm with invalid token" do
assert {:error, :not_found} = Accounts.confirm_user_by_token("oops")
end
test "does not confirm with expired token", %{token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert {:error, :not_found} = Accounts.confirm_user_by_token(token)
end
end
describe "change_user_registration/2" do
test "returns a changeset without hashing the password" do
changeset = Accounts.change_user_registration(%User{})
assert %Ecto.Changeset{} = changeset
assert :callsign in changeset.required
assert :name in changeset.required
assert :email in changeset.required
assert :password in changeset.required
end
end
describe "sudo_mode?/2" do
test "validates the authenticated_at time" do
now = DateTime.utc_now()
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)})
# minute override
refute Accounts.sudo_mode?(
%User{authenticated_at: DateTime.add(now, -11, :minute)},
-10
)
# not authenticated
refute Accounts.sudo_mode?(%User{})
end
end
describe "change_user_email/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
assert changeset.required == [:email]
end
end
describe "deliver_user_update_email_instructions/3" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "change:current@example.com"
end
end
describe "update_user_email/2" do
setup do
user = user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{user: user, token: token, email: email}
end
test "updates the email with a valid token", %{user: user, token: token, email: email} do
assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token)
changed_user = Repo.get!(User, user.id)
assert changed_user.email != user.email
assert changed_user.email == email
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email with invalid token", %{user: user} do
assert Accounts.update_user_email(user, "oops") ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if user email changed", %{user: user, token: token} do
assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.update_user_email(user, token) ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "change_user_password/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Accounts.change_user_password(
%User{},
%{
"password" => "new valid password"
},
hash_password: false
)
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_user_password/2" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, %{
password: "short",
password_confirmation: "other"
})
assert %{
password: ["should be at least 8 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.update_user_password(user, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, {user, expired_tokens}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
assert expired_tokens == []
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, {_, _}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "session"
assert user_token.authenticated_at
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "session"
})
end
end
test "duplicates the authenticated_at of given user in new token", %{user: user} do
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.authenticated_at == user.authenticated_at
assert DateTime.after?(user_token.inserted_at, user.authenticated_at)
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token)
assert session_user.id == user.id
assert session_user.authenticated_at
assert token_inserted_at
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_session_token("oops")
end
test "does not return user for expired token", %{token: token} do
dt = ~N[2020-01-01 00:00:00]
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt])
refute Accounts.get_user_by_session_token(token)
end
end
describe "delete_user_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token)
end
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end