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.
477 lines
15 KiB
Elixir
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
|