who_need_help/test/who_need_help/accounts_test.exs

780 lines
26 KiB
Elixir

defmodule WhoNeedHelp.FailingMailerAdapter do
use Swoosh.Adapter
@impl true
def deliver(_email, _config), do: {:error, :delivery_failed}
end
defmodule WhoNeedHelp.AccountsTest do
use WhoNeedHelp.DataCase
alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.Accounts.{SocialIdentity, StaffPermissions, User, 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)
assert %User{id: ^id} = Accounts.get_user_by_email(" #{String.upcase(user.email)} ")
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() |> set_password()
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() |> set_password()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(
" #{String.upcase(user.email)} ",
valid_user_password()
)
end
test "does not authenticate a suspended user with a valid password" do
user = user_fixture() |> set_password()
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
refute 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 "public_user_query/1" do
test "does not load authentication or moderation fields" do
user =
user_fixture()
|> set_password()
|> Ecto.Changeset.change(moderation_note: "private moderator note")
|> Repo.update!()
public_user =
Accounts.public_user_query()
|> where([candidate], candidate.id == ^user.id)
|> Repo.one!()
assert public_user.id == user.id
assert public_user.display_name == user.display_name
assert is_nil(public_user.email)
assert is_nil(public_user.hashed_password)
assert is_nil(public_user.confirmed_at)
assert is_nil(public_user.accepted_terms_at)
assert is_nil(public_user.moderation_note)
end
end
describe "get_public_user/1" do
test "loads only the public projection and public social identities" do
user =
user_fixture()
|> set_password()
|> Ecto.Changeset.change(
bio: "A short public bio",
moderation_note: "private moderator note"
)
|> Repo.update!()
_user = grant_staff_roles(user, [:admin])
{:ok, identity} =
Accounts.add_social_identity(user, %{
"provider" => "telegram",
"profile_url" => "https://t.me/public_profile_test",
"handle" => "@public_profile_test"
})
assert {:ok, public_user} = Accounts.get_public_user(user.id)
assert public_user.id == user.id
assert public_user.bio == "A short public bio"
assert [%{id: identity_id, profile_url: "https://t.me/public_profile_test"}] =
public_user.social_identities
assert identity_id == identity.id
assert is_nil(public_user.email)
assert is_nil(public_user.hashed_password)
assert %Ecto.Association.NotLoaded{} = public_user.staff_role_assignments
assert is_nil(public_user.moderation_note)
end
test "does not expose invalid, missing, or suspended accounts" do
user = user_fixture()
assert {:error, :not_found} = Accounts.get_public_user("not-a-uuid")
assert {:error, :not_found} =
Accounts.get_public_user("11111111-1111-1111-1111-111111111111")
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
assert {:error, :not_found} = Accounts.get_public_user(user.id)
end
end
describe "register_user/1" do
test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{})
assert %{email: ["can't be blank"]} = errors_on(changeset)
end
test "validates email when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid"})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum values for email for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{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(%{email: email})
assert "has already been taken" in errors_on(changeset).email
# Now try with the uppercased email too, to check that email case is ignored.
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
assert "has already been taken" in errors_on(changeset).email
end
test "registers users without password" do
email = unique_user_email()
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
assert user.email == email
assert is_nil(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_nil(user.password)
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 "staff roles and permissions" do
test "combines permissions from every assigned role and gives administrators all permissions" do
combined = staff_user_fixture([:support, :moderator])
assert Accounts.permission?(combined, :support_manage)
assert Accounts.permission?(combined, :moderation_manage)
assert Accounts.permission?(combined, :users_moderate)
refute Accounts.permission?(combined, :legal_manage)
refute Accounts.permission?(combined, :audit_view)
admin = staff_user_fixture([:admin])
assert Enum.all?(StaffPermissions.permissions(), &Accounts.permission?(admin, &1))
end
test "a restricted staff account keeps assignments but loses effective authorization" do
staff = staff_user_fixture([:support, :legal])
assert Accounts.authorized?(staff, :support_manage)
assert Accounts.authorized?(staff, :legal_manage)
restricted =
staff
|> User.moderation_changeset(%{
"moderation_status" => "restricted",
"moderation_note" => "Temporary staff access restriction"
})
|> Repo.update!()
assert Accounts.staff_roles(restricted) == [:legal, :support]
refute Accounts.authorized?(restricted, :support_manage)
refute Accounts.authorized?(restricted, :legal_manage)
end
end
describe "profile URL validation" do
test "accepts only encrypted thank-you and social profile URLs" do
user = user_fixture()
profile_attrs = %{
"display_name" => user.display_name,
"locale" => user.locale,
"location_visibility" => user.location_visibility,
"direct_message_policy" => user.direct_message_policy
}
assert User.profile_changeset(
user,
Map.put(profile_attrs, "tip_url", "https://example.com/thank-you")
).valid?
refute User.profile_changeset(
user,
Map.put(profile_attrs, "tip_url", "http://example.com/thank-you")
).valid?
social = %SocialIdentity{user_id: Ecto.UUID.generate()}
social_attrs = %{"provider" => "other", "profile_url" => "https://example.com/profile"}
assert SocialIdentity.changeset(social, social_attrs).valid?
refute SocialIdentity.changeset(
social,
Map.put(social_attrs, "profile_url", "http://example.com/profile")
).valid?
end
test "rejects a thank-you URL that cannot fit its database column" do
user = user_fixture()
changeset =
User.profile_changeset(user, %{
"display_name" => user.display_name,
"locale" => user.locale,
"location_visibility" => user.location_visibility,
"direct_message_policy" => user.direct_message_policy,
"tip_url" => "https://example.com/#{String.duplicate("x", 240)}"
})
assert "should be at most 255 character(s)" in errors_on(changeset).tip_url
end
test "rejects a social URL that cannot fit its database column" do
changeset =
SocialIdentity.changeset(
%SocialIdentity{user_id: Ecto.UUID.generate()},
%{
"provider" => "other",
"profile_url" => "https://example.com/#{String.duplicate("x", 240)}"
}
)
assert "should be at most 255 character(s)" in errors_on(changeset).profile_url
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 = unconfirmed_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: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 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 != nil
# 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.compare(user_token.inserted_at, user.authenticated_at) == :gt
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 != nil
assert token_inserted_at != nil
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 "get_user_by_magic_link_token/1" do
setup do
user = user_fixture()
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
%{user: user, token: encoded_token}
end
test "returns user by token", %{user: user, token: token} do
assert session_user = Accounts.get_user_by_magic_link_token(token)
assert session_user.id == user.id
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_magic_link_token("oops")
end
test "does not return user for expired token", %{token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_magic_link_token(token)
end
end
describe "login_user_by_magic_link/1" do
test "returns not found for a syntactically invalid token" do
assert {:error, :not_found} = Accounts.login_user_by_magic_link("%not-base64%")
end
test "confirms user and expires tokens" do
user = unconfirmed_user_fixture()
refute user.confirmed_at
{encoded_token, hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {user, [%{token: ^hashed_token}]}} =
Accounts.login_user_by_magic_link(encoded_token)
assert user.confirmed_at
end
test "returns user and (deleted) token for confirmed user" do
user = user_fixture()
assert user.confirmed_at
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token)
# one time use only
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "concurrent requests consume an unconfirmed user's token exactly once" do
user = unconfirmed_user_fixture()
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
results =
1..2
|> Task.async_stream(
fn _ -> Accounts.login_user_by_magic_link(encoded_token) end,
max_concurrency: 2,
ordered: false
)
|> Enum.map(fn {:ok, result} -> result end)
assert Enum.count(results, &match?({:ok, {%User{}, _tokens}}, &1)) == 1
assert Enum.count(results, &(&1 == {:error, :not_found})) == 1
assert Accounts.get_user!(user.id).confirmed_at
end
test "rejects a suspended user and consumes the magic link" do
user = user_fixture()
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
refute Accounts.get_user_by_magic_link_token(encoded_token)
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "raises when unconfirmed user has password set" do
user = unconfirmed_user_fixture()
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->
Accounts.login_user_by_magic_link(encoded_token)
end
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 "delete_expired_user_tokens/1" do
test "prunes each token context only after its configured validity" do
user = user_fixture()
now = DateTime.utc_now(:second)
expired_login =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "login",
sent_to: user.email,
inserted_at: DateTime.add(now, -16, :minute)
})
active_login =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "login",
sent_to: user.email,
inserted_at: DateTime.add(now, -14, :minute)
})
expired_change =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "change:#{user.email}",
sent_to: unique_user_email(),
inserted_at: DateTime.add(now, -8, :day)
})
expired_session =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "session",
inserted_at: DateTime.add(now, -15, :day)
})
assert {3, _} = Accounts.delete_expired_user_tokens(now)
refute Repo.get(UserToken, expired_login.id)
refute Repo.get(UserToken, expired_change.id)
refute Repo.get(UserToken, expired_session.id)
assert Repo.get(UserToken, active_login.id)
end
end
describe "get_user_by_session_token/1 moderation boundary" do
test "never authenticates a suspended user even if a token still exists" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
refute Accounts.get_user_by_session_token(token)
assert Repo.exists?(from(candidate in UserToken, where: candidate.token == ^token))
end
end
describe "deliver_login_instructions/2" do
setup do
%{user: unconfirmed_user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_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 == "login"
end
test "removes the newly created token when delivery fails", %{user: user} do
previous = Application.get_env(:who_need_help, WhoNeedHelp.Mailer)
Application.put_env(
:who_need_help,
WhoNeedHelp.Mailer,
adapter: WhoNeedHelp.FailingMailerAdapter
)
on_exit(fn ->
Application.put_env(:who_need_help, WhoNeedHelp.Mailer, previous)
end)
assert {:error, :delivery_failed} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
refute Repo.exists?(
from token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
)
end
test "does not send another login email while the previous link is valid", %{user: user} do
assert {:ok, first_email} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
assert {:ok, :already_sent} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
assert first_email.subject == "Confirm your Who Need Help account"
assert Repo.aggregate(
from(token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
),
:count
) == 1
end
test "allows a new login email after the existing link expires", %{user: user} do
assert {:ok, _email} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
Repo.update_all(
from(token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -16, :minute)]
)
assert {:ok, _email} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
assert Repo.aggregate(
from(token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
),
:count
) == 2
end
test "deduplicates only the same pending Google verification flow", %{user: user} do
url = &"https://example.test/google/[TOKEN]#{&1}[TOKEN]"
assert {:ok, first_email} =
Accounts.deliver_google_link_instructions(user, "pending-flow-one", url)
[_, token | _] = String.split(first_email.text_body, "[TOKEN]")
assert %User{id: user_id} = Accounts.get_user_by_magic_link_token(token)
assert user_id == user.id
assert {:ok, :already_sent} =
Accounts.deliver_google_link_instructions(user, "pending-flow-one", url)
assert {:ok, _email} =
Accounts.deliver_google_link_instructions(user, "pending-flow-two", url)
contexts =
UserToken
|> where([token], token.user_id == ^user.id)
|> select([token], token.context)
|> Repo.all()
assert length(contexts) == 2
assert Enum.all?(contexts, &String.starts_with?(&1, "login:google:"))
assert contexts |> MapSet.new() |> MapSet.size() == 2
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