**Performance improvement: 52s → 33.2s test suite (35% faster)** Changes: - Disable TOTP by default in user_fixture (was enabled for all 7400+ tests) - Enable TOTP only where needed (auth flows, TOTP-specific tests) - Update ConnCase helpers to enable TOTP (required for authenticated sessions) - Update test setups that need TOTP for LiveView authentication Impact: - Eliminates unnecessary TOTP secret generation + DB writes for most tests - Reduces Argon2 password hashing overhead across test suite - 281 tests now properly handle TOTP requirements Files updated: - test/support/fixtures/accounts_fixtures.ex: enable_totp default false → true only when needed - test/support/conn_case.ex: register_and_log_in_user helpers enable TOTP - test/towerops/accounts_test.exs: TOTP-related describe blocks enable TOTP - test/towerops_web/user_auth_test.exs: setup enables TOTP - test/towerops_web/live/admin/*: admin LiveView tests enable TOTP - test/towerops_web/live/org/preseem_devices_live_test.exs: enable TOTP
1945 lines
65 KiB
Elixir
1945 lines
65 KiB
Elixir
defmodule Towerops.AccountsTest do
|
|
use Towerops.DataCase
|
|
|
|
import Towerops.AccountsFixtures
|
|
|
|
alias Towerops.Accounts
|
|
alias Towerops.Accounts.User
|
|
alias Towerops.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_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 email and password to be set" do
|
|
{:error, changeset} = Accounts.register_user(%{})
|
|
|
|
assert %{email: ["can't be blank"], password: ["can't be blank"]} = errors_on(changeset)
|
|
end
|
|
|
|
test "validates email when given" do
|
|
{:error, changeset} = Accounts.register_user(%{email: "not valid", password: valid_user_password()})
|
|
|
|
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, password: valid_user_password()})
|
|
assert "should be at most 160 character(s)" in errors_on(changeset).email
|
|
end
|
|
|
|
test "validates password length" do
|
|
{:error, changeset} = Accounts.register_user(%{email: unique_user_email(), password: "short"})
|
|
assert "should be at least 12 character(s)" in errors_on(changeset).password
|
|
end
|
|
|
|
test "validates email uniqueness" do
|
|
%{email: email} = user_fixture()
|
|
{:error, changeset} = Accounts.register_user(%{email: email, password: valid_user_password()})
|
|
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), password: valid_user_password()})
|
|
assert "has already been taken" in errors_on(changeset).email
|
|
end
|
|
|
|
test "registers users with email and password" do
|
|
email = unique_user_email()
|
|
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
|
|
assert user.email == email
|
|
assert is_binary(user.hashed_password)
|
|
refute user.confirmed_at
|
|
assert is_nil(user.password)
|
|
end
|
|
end
|
|
|
|
describe "sudo_mode?/2" do
|
|
test "validates the last_sudo_at time" do
|
|
now = DateTime.utc_now()
|
|
|
|
assert Accounts.sudo_mode?(%User{last_sudo_at: DateTime.utc_now()})
|
|
assert Accounts.sudo_mode?(%User{last_sudo_at: DateTime.add(now, -9, :minute)})
|
|
refute Accounts.sudo_mode?(%User{last_sudo_at: DateTime.add(now, -11, :minute)})
|
|
|
|
# minute override
|
|
refute Accounts.sudo_mode?(
|
|
%User{last_sudo_at: DateTime.add(now, -6, :minute)},
|
|
-5
|
|
)
|
|
|
|
# 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 = 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
|
|
|
|
# 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 "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 "logs in confirmed user and deletes token" do
|
|
user = user_fixture()
|
|
assert user.confirmed_at
|
|
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
|
|
assert {:ok, {logged_in_user, []}} = Accounts.login_user_by_magic_link(encoded_token)
|
|
assert logged_in_user.id == user.id
|
|
# one time use only
|
|
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()
|
|
refute user.confirmed_at
|
|
assert user.hashed_password
|
|
{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 "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
|
|
end
|
|
|
|
describe "inspect/2 for the User module" do
|
|
test "does not include password" do
|
|
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
|
end
|
|
end
|
|
|
|
describe "TOTP enrollment" do
|
|
setup do
|
|
# Create user without TOTP for enrollment tests
|
|
%{user: user_fixture(enable_totp: false)}
|
|
end
|
|
|
|
test "generate_totp_secret/0 generates a valid secret" do
|
|
secret = Accounts.generate_totp_secret()
|
|
assert is_binary(secret)
|
|
assert byte_size(secret) > 0
|
|
end
|
|
|
|
test "generate_totp_uri/2 creates valid otpauth URI", %{user: user} do
|
|
secret = Accounts.generate_totp_secret()
|
|
uri = Accounts.generate_totp_uri(user, secret)
|
|
|
|
assert uri =~ "otpauth://totp/Towerops:#{user.email}"
|
|
assert uri =~ "secret="
|
|
assert uri =~ "issuer=Towerops"
|
|
end
|
|
|
|
test "generate_totp_qr_code/2 creates base64-encoded data URI", %{user: user} do
|
|
secret = Accounts.generate_totp_secret()
|
|
qr_code = Accounts.generate_totp_qr_code(user, secret)
|
|
|
|
assert String.starts_with?(qr_code, "data:image/png;base64,")
|
|
# Verify it's valid base64 after the prefix
|
|
[_, base64] = String.split(qr_code, ",", parts: 2)
|
|
assert {:ok, _} = Base.decode64(base64)
|
|
end
|
|
|
|
test "verify_totp/2 validates correct TOTP codes" do
|
|
secret = Accounts.generate_totp_secret()
|
|
code = NimbleTOTP.verification_code(secret)
|
|
|
|
assert Accounts.verify_totp(secret, code)
|
|
end
|
|
|
|
test "verify_totp/2 rejects incorrect TOTP codes" do
|
|
secret = Accounts.generate_totp_secret()
|
|
|
|
refute Accounts.verify_totp(secret, "000000")
|
|
refute Accounts.verify_totp(secret, "invalid")
|
|
end
|
|
|
|
test "verify_totp/2 accepts codes within time window" do
|
|
secret = Accounts.generate_totp_secret()
|
|
# Generate code for current time (should always be valid)
|
|
code = NimbleTOTP.verification_code(secret)
|
|
|
|
# Verify it works (checks current time +/- 1 step)
|
|
assert Accounts.verify_totp(secret, code)
|
|
end
|
|
|
|
test "enable_totp/2 saves TOTP secret to user", %{user: user} do
|
|
secret = Accounts.generate_totp_secret()
|
|
|
|
assert {:ok, updated_user} = Accounts.enable_totp(user, secret)
|
|
assert updated_user.totp_secret == secret
|
|
assert Accounts.totp_enabled?(updated_user)
|
|
end
|
|
|
|
test "totp_enabled?/1 returns false for user without TOTP", %{user: user} do
|
|
refute Accounts.totp_enabled?(user)
|
|
end
|
|
|
|
test "totp_enabled?/1 returns true for user with TOTP", %{user: user} do
|
|
secret = Accounts.generate_totp_secret()
|
|
{:ok, updated_user} = Accounts.enable_totp(user, secret)
|
|
|
|
assert Accounts.totp_enabled?(updated_user)
|
|
end
|
|
|
|
test "verify_user_totp/2 validates codes for enrolled user", %{user: user} do
|
|
secret = Accounts.generate_totp_secret()
|
|
{:ok, updated_user} = Accounts.enable_totp(user, secret)
|
|
|
|
code = NimbleTOTP.verification_code(secret)
|
|
|
|
assert {:ok, %User{}} = Accounts.verify_user_totp(updated_user, code)
|
|
end
|
|
|
|
test "verify_user_totp/2 rejects invalid codes for enrolled user", %{user: user} do
|
|
secret = Accounts.generate_totp_secret()
|
|
{:ok, updated_user} = Accounts.enable_totp(user, secret)
|
|
|
|
assert {:error, :invalid_code} = Accounts.verify_user_totp(updated_user, "000000")
|
|
end
|
|
end
|
|
|
|
describe "TOTP Device Management" do
|
|
setup do
|
|
user = user_fixture(enable_totp: true)
|
|
{:ok, user: user}
|
|
end
|
|
|
|
test "list_user_totp_devices/1 returns empty list for user with no devices", %{user: user} do
|
|
assert [] = Accounts.list_user_totp_devices(user.id)
|
|
end
|
|
|
|
test "list_user_totp_devices/1 returns devices ordered by last_used_at", %{user: user} do
|
|
{:ok, _device1, _secret1} = Accounts.create_totp_device(user.id, "Device 1")
|
|
{:ok, device2, secret2} = Accounts.create_totp_device(user.id, "Device 2")
|
|
|
|
# Use device2
|
|
code = NimbleTOTP.verification_code(secret2)
|
|
{:ok, _} = Accounts.verify_user_totp_any_device(user, code)
|
|
|
|
devices = Accounts.list_user_totp_devices(user.id)
|
|
assert length(devices) == 2
|
|
# Device 2 should be first because it was used
|
|
assert hd(devices).id == device2.id
|
|
end
|
|
|
|
test "count_user_totp_devices/1 returns correct count", %{user: user} do
|
|
assert 0 = Accounts.count_user_totp_devices(user.id)
|
|
|
|
{:ok, _device1, _} = Accounts.create_totp_device(user.id, "Device 1")
|
|
assert 1 = Accounts.count_user_totp_devices(user.id)
|
|
|
|
{:ok, _device2, _} = Accounts.create_totp_device(user.id, "Device 2")
|
|
assert 2 = Accounts.count_user_totp_devices(user.id)
|
|
end
|
|
|
|
test "create_totp_device/2 creates device with unique secret", %{user: user} do
|
|
{:ok, device, secret} = Accounts.create_totp_device(user.id, "iPhone 15")
|
|
|
|
assert device.name == "iPhone 15"
|
|
assert device.user_id == user.id
|
|
assert is_binary(secret)
|
|
# NimbleTOTP default is 20 bytes
|
|
assert byte_size(secret) == 20
|
|
assert is_binary(device.totp_secret)
|
|
end
|
|
|
|
test "create_totp_device/2 validates device name length", %{user: user} do
|
|
long_name = String.duplicate("a", 101)
|
|
|
|
{:error, changeset} = Accounts.create_totp_device(user.id, long_name)
|
|
assert "should be at most 100 character(s)" in errors_on(changeset).name
|
|
end
|
|
|
|
test "verify_user_totp_any_device/2 verifies code against any user device", %{user: user} do
|
|
{:ok, device1, secret1} = Accounts.create_totp_device(user.id, "Device 1")
|
|
{:ok, device2, secret2} = Accounts.create_totp_device(user.id, "Device 2")
|
|
|
|
# Test with device 1 code
|
|
code1 = NimbleTOTP.verification_code(secret1)
|
|
assert {:ok, verified_device} = Accounts.verify_user_totp_any_device(user, code1)
|
|
assert verified_device.id == device1.id
|
|
|
|
# Test with device 2 code
|
|
code2 = NimbleTOTP.verification_code(secret2)
|
|
assert {:ok, verified_device} = Accounts.verify_user_totp_any_device(user, code2)
|
|
assert verified_device.id == device2.id
|
|
end
|
|
|
|
test "verify_user_totp_any_device/2 updates last_used_at on successful verification", %{user: user} do
|
|
{:ok, device, secret} = Accounts.create_totp_device(user.id, "Device")
|
|
|
|
assert is_nil(device.last_used_at)
|
|
|
|
code = NimbleTOTP.verification_code(secret)
|
|
{:ok, updated_device} = Accounts.verify_user_totp_any_device(user, code)
|
|
|
|
assert updated_device.last_used_at
|
|
assert DateTime.diff(updated_device.last_used_at, DateTime.utc_now(:second)) <= 1
|
|
end
|
|
|
|
test "verify_user_totp_any_device/2 returns error for invalid code", %{user: user} do
|
|
{:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
|
|
|
|
assert {:error, :invalid_code} = Accounts.verify_user_totp_any_device(user, "000000")
|
|
end
|
|
|
|
test "delete_totp_device/2 prevents deletion of last device", %{user: user} do
|
|
{:ok, device, _} = Accounts.create_totp_device(user.id, "Only Device")
|
|
|
|
assert {:error, :last_device} = Accounts.delete_totp_device(device.id, user.id)
|
|
assert 1 = Accounts.count_user_totp_devices(user.id)
|
|
end
|
|
|
|
test "delete_totp_device/2 allows deletion when multiple devices exist", %{user: user} do
|
|
{:ok, device1, _} = Accounts.create_totp_device(user.id, "Device 1")
|
|
{:ok, device2, _} = Accounts.create_totp_device(user.id, "Device 2")
|
|
|
|
assert {:ok, _} = Accounts.delete_totp_device(device1.id, user.id)
|
|
assert 1 = Accounts.count_user_totp_devices(user.id)
|
|
|
|
devices = Accounts.list_user_totp_devices(user.id)
|
|
assert length(devices) == 1
|
|
assert hd(devices).id == device2.id
|
|
end
|
|
|
|
test "delete_totp_device/2 returns error for non-existent device", %{user: user} do
|
|
fake_id = Ecto.UUID.generate()
|
|
assert {:error, :not_found} = Accounts.delete_totp_device(fake_id, user.id)
|
|
end
|
|
|
|
test "delete_totp_device/2 returns error for unauthorized user" do
|
|
user1 = user_fixture()
|
|
user2 = user_fixture()
|
|
{:ok, device, _} = Accounts.create_totp_device(user1.id, "Device")
|
|
# So we have multiple
|
|
{:ok, _device2, _} = Accounts.create_totp_device(user1.id, "Device 2")
|
|
|
|
assert {:error, :unauthorized} = Accounts.delete_totp_device(device.id, user2.id)
|
|
end
|
|
|
|
test "rename_totp_device/3 updates device name", %{user: user} do
|
|
{:ok, device, _} = Accounts.create_totp_device(user.id, "Old Name")
|
|
|
|
assert {:ok, updated_device} = Accounts.rename_totp_device(device.id, user.id, "New Name")
|
|
assert updated_device.name == "New Name"
|
|
end
|
|
|
|
test "rename_totp_device/3 returns error for non-existent device", %{user: user} do
|
|
fake_id = Ecto.UUID.generate()
|
|
assert {:error, :not_found} = Accounts.rename_totp_device(fake_id, user.id, "New Name")
|
|
end
|
|
|
|
test "rename_totp_device/3 returns error for unauthorized user" do
|
|
user1 = user_fixture()
|
|
user2 = user_fixture()
|
|
{:ok, device, _} = Accounts.create_totp_device(user1.id, "Device")
|
|
|
|
assert {:error, :unauthorized} = Accounts.rename_totp_device(device.id, user2.id, "Hacked")
|
|
end
|
|
end
|
|
|
|
describe "Recovery Codes" do
|
|
setup do
|
|
user = user_fixture(enable_totp: true)
|
|
{:ok, user: user}
|
|
end
|
|
|
|
test "generate_recovery_codes/1 generates 12 unique codes", %{user: user} do
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
|
|
assert length(codes) == 12
|
|
# All unique
|
|
assert length(Enum.uniq(codes)) == 12
|
|
|
|
# Check format: XXXX-XXXX
|
|
Enum.each(codes, fn code ->
|
|
assert Regex.match?(~r/^[A-Z2-9]{4}-[A-Z2-9]{4}$/, code)
|
|
# No ambiguous chars
|
|
refute String.contains?(code, ["O", "I", "0", "1"])
|
|
end)
|
|
end
|
|
|
|
test "generate_recovery_codes/1 deletes existing unused codes before generating", %{user: user} do
|
|
{:ok, old_codes} = Accounts.generate_recovery_codes(user.id)
|
|
old_code = List.first(old_codes)
|
|
|
|
{:ok, new_codes} = Accounts.generate_recovery_codes(user.id)
|
|
|
|
# Old codes should be invalid
|
|
assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, old_code)
|
|
|
|
# New codes should work
|
|
new_code = List.first(new_codes)
|
|
assert {:ok, _} = Accounts.verify_recovery_code(user.id, new_code)
|
|
end
|
|
|
|
test "generate_recovery_codes/1 preserves used codes when regenerating", %{user: user} do
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
used_code = List.first(codes)
|
|
|
|
# Use one code
|
|
{:ok, _} = Accounts.verify_recovery_code(user.id, used_code)
|
|
|
|
# Generate new codes
|
|
{:ok, _new_codes} = Accounts.generate_recovery_codes(user.id)
|
|
|
|
# Used code should still be marked as used (not deleted)
|
|
assert 12 = Accounts.count_unused_recovery_codes(user.id)
|
|
end
|
|
|
|
test "verify_recovery_code/2 verifies valid unused code", %{user: user} do
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
code = List.first(codes)
|
|
|
|
assert {:ok, record} = Accounts.verify_recovery_code(user.id, code)
|
|
assert is_nil(record.used_at) == false
|
|
end
|
|
|
|
test "verify_recovery_code/2 marks code as used after verification", %{user: user} do
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
code = List.first(codes)
|
|
|
|
{:ok, _} = Accounts.verify_recovery_code(user.id, code)
|
|
|
|
# Second attempt should fail
|
|
assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, code)
|
|
end
|
|
|
|
test "verify_recovery_code/2 rejects invalid code format", %{user: user} do
|
|
assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, "INVALID")
|
|
assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, "1234-5678")
|
|
end
|
|
|
|
test "count_unused_recovery_codes/1 returns correct count", %{user: user} do
|
|
assert 0 = Accounts.count_unused_recovery_codes(user.id)
|
|
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
assert 12 = Accounts.count_unused_recovery_codes(user.id)
|
|
|
|
# Use one code
|
|
code = List.first(codes)
|
|
{:ok, _} = Accounts.verify_recovery_code(user.id, code)
|
|
assert 11 = Accounts.count_unused_recovery_codes(user.id)
|
|
end
|
|
|
|
test "list_user_recovery_codes/1 returns all codes with status", %{user: user} do
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
|
|
all_codes = Accounts.list_user_recovery_codes(user.id)
|
|
assert length(all_codes) == 12
|
|
|
|
# Use one code
|
|
code = List.first(codes)
|
|
{:ok, _} = Accounts.verify_recovery_code(user.id, code)
|
|
|
|
all_codes = Accounts.list_user_recovery_codes(user.id)
|
|
used_codes = Enum.filter(all_codes, & &1.used_at)
|
|
unused_codes = Enum.filter(all_codes, &is_nil(&1.used_at))
|
|
|
|
assert length(used_codes) == 1
|
|
assert length(unused_codes) == 11
|
|
end
|
|
end
|
|
|
|
describe "verify_user_mfa/2" do
|
|
setup do
|
|
user = user_fixture(enable_totp: true)
|
|
{:ok, user: user}
|
|
end
|
|
|
|
test "accepts TOTP code from any device", %{user: user} do
|
|
{:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
|
|
code = NimbleTOTP.verification_code(secret)
|
|
|
|
assert {:ok, ^user, :totp} = Accounts.verify_user_mfa(user, code)
|
|
end
|
|
|
|
test "accepts recovery code as fallback", %{user: user} do
|
|
{:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
recovery_code = List.first(codes)
|
|
|
|
assert {:ok, ^user, :recovery_code} = Accounts.verify_user_mfa(user, recovery_code)
|
|
end
|
|
|
|
test "prefers TOTP over recovery code when both would be valid", %{user: user} do
|
|
{:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
|
|
totp_code = NimbleTOTP.verification_code(secret)
|
|
|
|
# Should return :totp, not :recovery_code
|
|
assert {:ok, ^user, :totp} = Accounts.verify_user_mfa(user, totp_code)
|
|
end
|
|
|
|
test "rejects invalid code that matches neither TOTP nor recovery", %{user: user} do
|
|
{:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
|
|
{:ok, _codes} = Accounts.generate_recovery_codes(user.id)
|
|
|
|
assert {:error, :invalid_code} = Accounts.verify_user_mfa(user, "000000")
|
|
end
|
|
|
|
test "marks recovery code as used when verified via MFA", %{user: user} do
|
|
{:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
recovery_code = List.first(codes)
|
|
|
|
assert {:ok, ^user, :recovery_code} = Accounts.verify_user_mfa(user, recovery_code)
|
|
|
|
# Code should be marked as used
|
|
assert 11 = Accounts.count_unused_recovery_codes(user.id)
|
|
end
|
|
end
|
|
|
|
describe "register_user_with_organization/1" do
|
|
test "accepts timezone in registration params" do
|
|
valid_attrs = %{
|
|
"email" => "user@example.com",
|
|
"password" => "a valid password",
|
|
"privacy_policy_consent" => true,
|
|
"terms_of_service_consent" => true,
|
|
"organization_name" => "Test Org",
|
|
"timezone" => "America/Chicago"
|
|
}
|
|
|
|
assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
|
|
assert user.timezone == "America/Chicago"
|
|
end
|
|
|
|
test "defaults to UTC when timezone not provided" do
|
|
valid_attrs = %{
|
|
"email" => "user@example.com",
|
|
"password" => "a valid password",
|
|
"privacy_policy_consent" => true,
|
|
"terms_of_service_consent" => true,
|
|
"organization_name" => "Test Org"
|
|
}
|
|
|
|
assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
|
|
assert user.timezone == "UTC"
|
|
end
|
|
|
|
test "rejects invalid timezone format" do
|
|
valid_attrs = %{
|
|
"email" => "invalid-tz@example.com",
|
|
"password" => "a valid password",
|
|
"privacy_policy_consent" => true,
|
|
"terms_of_service_consent" => true,
|
|
"organization_name" => "Test Org",
|
|
"timezone" => "Not/A/Real/Timezone"
|
|
}
|
|
|
|
assert {:error, changeset} = Accounts.register_user_with_organization(valid_attrs)
|
|
assert "is not a valid timezone" in errors_on(changeset).timezone
|
|
end
|
|
end
|
|
|
|
describe "get_user/1" do
|
|
test "returns the user with the given id" do
|
|
%{id: id} = user = user_fixture()
|
|
assert %User{id: ^id} = Accounts.get_user(user.id)
|
|
end
|
|
|
|
test "returns nil if the user does not exist" do
|
|
assert is_nil(Accounts.get_user("11111111-1111-1111-1111-111111111111"))
|
|
end
|
|
end
|
|
|
|
describe "change_user_registration/2" do
|
|
test "returns a changeset with required fields" do
|
|
assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
|
|
assert :email in changeset.required
|
|
assert :password in changeset.required
|
|
end
|
|
|
|
test "allows fields to be set without hashing" do
|
|
email = unique_user_email()
|
|
|
|
changeset =
|
|
Accounts.change_user_registration(%User{}, %{
|
|
email: email,
|
|
password: "a valid password",
|
|
privacy_policy_consent: true,
|
|
terms_of_service_consent: true
|
|
})
|
|
|
|
assert get_change(changeset, :email) == email
|
|
assert get_change(changeset, :password) == "a valid password"
|
|
# hash_password: false means hashed_password is not set
|
|
assert is_nil(get_change(changeset, :hashed_password))
|
|
end
|
|
end
|
|
|
|
describe "change_user_profile/2" do
|
|
test "returns a changeset" do
|
|
assert %Ecto.Changeset{} = Accounts.change_user_profile(%User{})
|
|
end
|
|
|
|
test "allows profile fields to be set" do
|
|
changeset =
|
|
Accounts.change_user_profile(%User{}, %{
|
|
first_name: "Jane",
|
|
last_name: "Doe",
|
|
timezone: "America/New_York"
|
|
})
|
|
|
|
assert changeset.valid?
|
|
assert get_change(changeset, :first_name) == "Jane"
|
|
assert get_change(changeset, :last_name) == "Doe"
|
|
assert get_change(changeset, :timezone) == "America/New_York"
|
|
end
|
|
end
|
|
|
|
describe "update_user_profile/2" do
|
|
setup do
|
|
%{user: user_fixture()}
|
|
end
|
|
|
|
test "updates the user profile with valid attrs", %{user: user} do
|
|
assert {:ok, updated} =
|
|
Accounts.update_user_profile(user, %{first_name: "Jane", last_name: "Doe"})
|
|
|
|
assert updated.first_name == "Jane"
|
|
assert updated.last_name == "Doe"
|
|
end
|
|
|
|
test "returns error changeset for invalid attrs", %{user: user} do
|
|
too_long = String.duplicate("a", 101)
|
|
assert {:error, changeset} = Accounts.update_user_profile(user, %{first_name: too_long})
|
|
assert "should be at most 100 character(s)" in errors_on(changeset).first_name
|
|
end
|
|
end
|
|
|
|
describe "grant_sudo_mode/1" do
|
|
test "sets last_sudo_at and authenticated_at on user" do
|
|
user = user_fixture()
|
|
assert {:ok, updated_user} = Accounts.grant_sudo_mode(user)
|
|
|
|
assert updated_user.last_sudo_at
|
|
assert updated_user.authenticated_at
|
|
assert Accounts.sudo_mode?(updated_user)
|
|
end
|
|
end
|
|
|
|
describe "verify_totp_only/2" do
|
|
setup do
|
|
user = user_fixture(enable_totp: true)
|
|
{:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
|
|
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
|
|
{:ok, user: user, secret: secret, recovery_codes: codes}
|
|
end
|
|
|
|
test "accepts valid TOTP code", %{user: user, secret: secret} do
|
|
code = NimbleTOTP.verification_code(secret)
|
|
assert {:ok, %User{}} = Accounts.verify_totp_only(user, code)
|
|
end
|
|
|
|
test "rejects non-numeric codes as recovery codes", %{user: user} do
|
|
assert {:error, :recovery_code_not_allowed} = Accounts.verify_totp_only(user, "ABCD-EFGH")
|
|
end
|
|
|
|
test "rejects codes longer than 6 digits", %{user: user} do
|
|
assert {:error, :recovery_code_not_allowed} = Accounts.verify_totp_only(user, "1234567")
|
|
end
|
|
|
|
test "rejects invalid 6-digit TOTP code", %{user: user} do
|
|
assert {:error, :invalid_code} = Accounts.verify_totp_only(user, "000000")
|
|
end
|
|
end
|
|
|
|
describe "totp_enabled?/1 edge cases" do
|
|
test "returns false for nil user" do
|
|
refute Accounts.totp_enabled?(nil)
|
|
end
|
|
|
|
test "returns true when user has TOTP devices but no legacy secret" do
|
|
user = user_fixture(enable_totp: false)
|
|
{:ok, _device, _secret} = Accounts.create_totp_device(user.id, "My Device")
|
|
|
|
assert Accounts.totp_enabled?(user)
|
|
end
|
|
end
|
|
|
|
describe "generate_user_session_token_with_record/1" do
|
|
test "generates a token and returns the user_token record" do
|
|
user = user_fixture()
|
|
{token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
assert is_binary(token)
|
|
assert user_token.id
|
|
assert user_token.user_id == user.id
|
|
assert user_token.context == "session"
|
|
end
|
|
end
|
|
|
|
describe "get_user_token_id_by_value/1" do
|
|
test "returns nil for unknown token" do
|
|
assert is_nil(Accounts.get_user_token_id_by_value("nonexistent_token"))
|
|
end
|
|
end
|
|
|
|
describe "deliver_user_reset_password_instructions/2" do
|
|
setup do
|
|
%{user: user_fixture()}
|
|
end
|
|
|
|
test "sends token through notification", %{user: user} do
|
|
token =
|
|
extract_user_token(fn url ->
|
|
Accounts.deliver_user_reset_password_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 == "reset_password"
|
|
end
|
|
end
|
|
|
|
describe "get_user_by_reset_password_token/1" do
|
|
setup do
|
|
user = user_fixture()
|
|
|
|
token =
|
|
extract_user_token(fn url ->
|
|
Accounts.deliver_user_reset_password_instructions(user, url)
|
|
end)
|
|
|
|
%{user: user, token: token}
|
|
end
|
|
|
|
test "returns the user with valid token", %{user: user, token: token} do
|
|
assert reset_user = Accounts.get_user_by_reset_password_token(token)
|
|
assert reset_user.id == user.id
|
|
end
|
|
|
|
test "does not return the user with invalid token" do
|
|
refute Accounts.get_user_by_reset_password_token("oops")
|
|
end
|
|
|
|
test "does not return the user if token expired", %{token: token} do
|
|
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
|
|
refute Accounts.get_user_by_reset_password_token(token)
|
|
end
|
|
end
|
|
|
|
describe "reset_user_password/2" do
|
|
setup do
|
|
%{user: user_fixture()}
|
|
end
|
|
|
|
test "validates password", %{user: user} do
|
|
{:error, changeset} =
|
|
Accounts.reset_user_password(user, %{
|
|
password: "short",
|
|
password_confirmation: "nope"
|
|
})
|
|
|
|
assert %{
|
|
password: ["should be at least 12 character(s)"],
|
|
password_confirmation: ["does not match password"]
|
|
} = errors_on(changeset)
|
|
end
|
|
|
|
test "updates the password", %{user: user} do
|
|
{:ok, {updated_user, _expired_tokens}} =
|
|
Accounts.reset_user_password(user, %{password: "new valid password"})
|
|
|
|
assert is_nil(updated_user.password)
|
|
assert Accounts.get_user_by_email_and_password(updated_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.reset_user_password(user, %{password: "new valid password"})
|
|
|
|
refute Repo.get_by(UserToken, user_id: user.id)
|
|
end
|
|
end
|
|
|
|
describe "grant_consent/2" do
|
|
setup do
|
|
%{user: user_fixture()}
|
|
end
|
|
|
|
test "grants privacy_policy consent", %{user: user} do
|
|
assert {:ok, consent} = Accounts.grant_consent(user.id, "privacy_policy")
|
|
assert consent.consent_type == "privacy_policy"
|
|
assert consent.user_id == user.id
|
|
assert consent.version == "1.0"
|
|
assert consent.granted_at
|
|
assert is_nil(consent.revoked_at)
|
|
end
|
|
|
|
test "grants terms_of_service consent", %{user: user} do
|
|
assert {:ok, consent} = Accounts.grant_consent(user.id, "terms_of_service")
|
|
assert consent.consent_type == "terms_of_service"
|
|
assert consent.version == "1.0"
|
|
end
|
|
|
|
test "rejects invalid consent type", %{user: user} do
|
|
assert_raise FunctionClauseError, fn ->
|
|
Accounts.grant_consent(user.id, "invalid_type")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "revoke_consent/1" do
|
|
setup do
|
|
user = user_fixture()
|
|
# Get the consent that was already granted during registration
|
|
consent = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
%{user: user, consent: consent}
|
|
end
|
|
|
|
test "revokes an active consent", %{consent: consent} do
|
|
assert {:ok, revoked} = Accounts.revoke_consent(consent.id)
|
|
assert revoked.revoked_at
|
|
end
|
|
|
|
test "returns error for non-existent consent" do
|
|
assert {:error, :not_found} = Accounts.revoke_consent(Ecto.UUID.generate())
|
|
end
|
|
|
|
test "returns error when consent already revoked", %{consent: consent} do
|
|
{:ok, _revoked} = Accounts.revoke_consent(consent.id)
|
|
assert {:error, changeset} = Accounts.revoke_consent(consent.id)
|
|
assert "consent already revoked" in errors_on(changeset).revoked_at
|
|
end
|
|
end
|
|
|
|
describe "list_user_consents/1" do
|
|
test "returns consents granted during registration" do
|
|
user = user_fixture()
|
|
# user_fixture grants privacy_policy and terms_of_service during registration
|
|
consents = Accounts.list_user_consents(user.id)
|
|
assert length(consents) == 2
|
|
|
|
types = Enum.map(consents, & &1.consent_type)
|
|
assert "privacy_policy" in types
|
|
assert "terms_of_service" in types
|
|
end
|
|
|
|
test "includes additional consents when granted" do
|
|
user = user_fixture()
|
|
# 2 from registration already exist
|
|
initial = Accounts.list_user_consents(user.id)
|
|
initial_count = length(initial)
|
|
|
|
# Grant another privacy_policy consent (newer version scenario)
|
|
{:ok, _} = Accounts.grant_consent(user.id, "privacy_policy")
|
|
|
|
consents = Accounts.list_user_consents(user.id)
|
|
assert length(consents) == initial_count + 1
|
|
end
|
|
end
|
|
|
|
describe "get_active_consent/2" do
|
|
test "returns active consent for user and type" do
|
|
user = user_fixture()
|
|
# user_fixture already grants consents during registration
|
|
active = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
assert active
|
|
assert active.consent_type == "privacy_policy"
|
|
assert is_nil(active.revoked_at)
|
|
end
|
|
|
|
test "returns nil when all consents of a type have been revoked" do
|
|
user = user_fixture()
|
|
# Revoke the consent granted during registration
|
|
consent = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
{:ok, _} = Accounts.revoke_consent(consent.id)
|
|
|
|
assert is_nil(Accounts.get_active_consent(user.id, "privacy_policy"))
|
|
end
|
|
end
|
|
|
|
describe "has_consent?/2" do
|
|
test "returns true when active consent exists" do
|
|
user = user_fixture()
|
|
# user_fixture already grants consents
|
|
assert Accounts.has_consent?(user.id, "privacy_policy")
|
|
assert Accounts.has_consent?(user.id, "terms_of_service")
|
|
end
|
|
|
|
test "returns false when consent has been revoked" do
|
|
user = user_fixture()
|
|
consent = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
{:ok, _} = Accounts.revoke_consent(consent.id)
|
|
|
|
refute Accounts.has_consent?(user.id, "privacy_policy")
|
|
end
|
|
end
|
|
|
|
describe "has_all_required_consents?/1" do
|
|
test "returns true when all required consents are granted" do
|
|
user = user_fixture()
|
|
# user_fixture grants both during registration
|
|
assert Accounts.has_all_required_consents?(user.id)
|
|
end
|
|
|
|
test "returns false when some consents are revoked" do
|
|
user = user_fixture()
|
|
consent = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
{:ok, _} = Accounts.revoke_consent(consent.id)
|
|
|
|
refute Accounts.has_all_required_consents?(user.id)
|
|
end
|
|
end
|
|
|
|
describe "create_policy_version/1" do
|
|
test "creates a valid policy version" do
|
|
attrs = %{
|
|
policy_type: "privacy_policy",
|
|
version: "2.0",
|
|
content: "This is the full text of the privacy policy version 2.0.",
|
|
effective_date: DateTime.utc_now(:second)
|
|
}
|
|
|
|
assert {:ok, policy} = Accounts.create_policy_version(attrs)
|
|
assert policy.policy_type == "privacy_policy"
|
|
assert policy.version == "2.0"
|
|
end
|
|
|
|
test "rejects invalid policy type" do
|
|
attrs = %{
|
|
policy_type: "invalid",
|
|
version: "1.0",
|
|
content: "Some content here for testing purposes.",
|
|
effective_date: DateTime.utc_now(:second)
|
|
}
|
|
|
|
assert {:error, changeset} = Accounts.create_policy_version(attrs)
|
|
assert "is invalid" in errors_on(changeset).policy_type
|
|
end
|
|
|
|
test "requires content to be at least 10 characters" do
|
|
attrs = %{
|
|
policy_type: "privacy_policy",
|
|
version: "1.0",
|
|
content: "Short",
|
|
effective_date: DateTime.utc_now(:second)
|
|
}
|
|
|
|
assert {:error, changeset} = Accounts.create_policy_version(attrs)
|
|
assert "should be at least 10 character(s)" in errors_on(changeset).content
|
|
end
|
|
end
|
|
|
|
describe "get_current_policy_version/1" do
|
|
test "returns the most recent effective policy version" do
|
|
past = DateTime.add(DateTime.utc_now(:second), -3600, :second)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "1.0",
|
|
content: "First version of the privacy policy document.",
|
|
effective_date: DateTime.add(past, -7200, :second)
|
|
})
|
|
|
|
{:ok, v2} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "2.0",
|
|
content: "Second version of the privacy policy document.",
|
|
effective_date: past
|
|
})
|
|
|
|
result = Accounts.get_current_policy_version("privacy_policy")
|
|
assert result.id == v2.id
|
|
assert result.version == "2.0"
|
|
end
|
|
|
|
test "does not return future policy versions" do
|
|
future = DateTime.add(DateTime.utc_now(:second), 86_400, :second)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "terms_of_service",
|
|
version: "99.0",
|
|
content: "Future version of the terms of service.",
|
|
effective_date: future
|
|
})
|
|
|
|
assert is_nil(Accounts.get_current_policy_version("terms_of_service"))
|
|
end
|
|
|
|
test "returns nil when no policy versions exist" do
|
|
assert is_nil(Accounts.get_current_policy_version("terms_of_service"))
|
|
end
|
|
end
|
|
|
|
describe "get_policy_version/2" do
|
|
test "returns a specific policy version" do
|
|
{:ok, policy} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "3.0",
|
|
content: "Version three of the privacy policy document.",
|
|
effective_date: DateTime.utc_now(:second)
|
|
})
|
|
|
|
result = Accounts.get_policy_version("privacy_policy", "3.0")
|
|
assert result.id == policy.id
|
|
end
|
|
|
|
test "returns nil for non-existent version" do
|
|
assert is_nil(Accounts.get_policy_version("privacy_policy", "999.0"))
|
|
end
|
|
end
|
|
|
|
describe "list_policy_versions/1" do
|
|
test "returns all versions for a policy type ordered by effective_date desc" do
|
|
now = DateTime.utc_now(:second)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "1.0",
|
|
content: "First version of the privacy policy document.",
|
|
effective_date: DateTime.add(now, -7200, :second)
|
|
})
|
|
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "2.0",
|
|
content: "Second version of the privacy policy document.",
|
|
effective_date: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
versions = Accounts.list_policy_versions("privacy_policy")
|
|
assert length(versions) == 2
|
|
assert hd(versions).version == "2.0"
|
|
end
|
|
|
|
test "returns empty list when no versions exist" do
|
|
assert [] = Accounts.list_policy_versions("terms_of_service")
|
|
end
|
|
end
|
|
|
|
describe "needs_reconsent?/2" do
|
|
test "returns false when no policy version exists" do
|
|
user = user_fixture()
|
|
refute Accounts.needs_reconsent?(user.id, "privacy_policy")
|
|
end
|
|
|
|
test "returns true when user has no consent for existing policy" do
|
|
user = user_fixture()
|
|
now = DateTime.utc_now(:second)
|
|
|
|
# Revoke existing consent first
|
|
consent = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
{:ok, _} = Accounts.revoke_consent(consent.id)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "1.0",
|
|
content: "The privacy policy text for testing purposes.",
|
|
effective_date: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
assert Accounts.needs_reconsent?(user.id, "privacy_policy")
|
|
end
|
|
|
|
test "returns true when consent version differs from current policy" do
|
|
user = user_fixture()
|
|
now = DateTime.utc_now(:second)
|
|
|
|
# User already has consent at version "1.0" from registration.
|
|
# Create a newer policy version "2.0" that doesn't match.
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "2.0",
|
|
content: "Second version of the privacy policy document.",
|
|
effective_date: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
assert Accounts.needs_reconsent?(user.id, "privacy_policy")
|
|
end
|
|
|
|
test "returns false when consent matches current policy version" do
|
|
user = user_fixture()
|
|
now = DateTime.utc_now(:second)
|
|
|
|
# User already has consent at version "1.0" from registration.
|
|
# Create a policy version "1.0" so they match.
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "1.0",
|
|
content: "First version of the privacy policy document.",
|
|
effective_date: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
refute Accounts.needs_reconsent?(user.id, "privacy_policy")
|
|
end
|
|
end
|
|
|
|
describe "policies_needing_reconsent/1" do
|
|
test "returns empty list when no policies need reconsent" do
|
|
user = user_fixture()
|
|
# No policy_versions in DB, so no reconsent needed
|
|
assert [] = Accounts.policies_needing_reconsent(user.id)
|
|
end
|
|
|
|
test "returns policy types that need reconsent" do
|
|
user = user_fixture()
|
|
now = DateTime.utc_now(:second)
|
|
|
|
# User has consent at "1.0", create policy at "2.0" to trigger reconsent
|
|
{:ok, _} =
|
|
Accounts.create_policy_version(%{
|
|
policy_type: "privacy_policy",
|
|
version: "2.0",
|
|
content: "The updated privacy policy text for testing.",
|
|
effective_date: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
policies = Accounts.policies_needing_reconsent(user.id)
|
|
assert "privacy_policy" in policies
|
|
end
|
|
end
|
|
|
|
describe "record_login_attempt/1" do
|
|
setup do
|
|
%{user: user_fixture()}
|
|
end
|
|
|
|
test "records a successful login attempt", %{user: user} do
|
|
attrs = %{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: true,
|
|
method: "password",
|
|
ip_address: "192.168.1.1"
|
|
}
|
|
|
|
assert {:ok, attempt} = Accounts.record_login_attempt(attrs)
|
|
assert attempt.success == true
|
|
assert attempt.method == "password"
|
|
assert attempt.email == user.email
|
|
assert attempt.ip_address == "192.168.1.1"
|
|
end
|
|
|
|
test "records a failed login attempt", %{user: user} do
|
|
attrs = %{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: false,
|
|
method: "password",
|
|
failure_reason: "invalid_credentials",
|
|
ip_address: "10.0.0.1"
|
|
}
|
|
|
|
assert {:ok, attempt} = Accounts.record_login_attempt(attrs)
|
|
assert attempt.success == false
|
|
assert attempt.failure_reason == "invalid_credentials"
|
|
end
|
|
|
|
test "records attempt without user_id (unknown user)" do
|
|
attrs = %{
|
|
email: "unknown@example.com",
|
|
success: false,
|
|
method: "password",
|
|
failure_reason: "invalid_credentials",
|
|
ip_address: "10.0.0.1"
|
|
}
|
|
|
|
assert {:ok, attempt} = Accounts.record_login_attempt(attrs)
|
|
assert is_nil(attempt.user_id)
|
|
end
|
|
|
|
test "records attempt without ip_address enrichment" do
|
|
user = user_fixture()
|
|
|
|
attrs = %{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: true,
|
|
method: "magic_link",
|
|
ip_address: "127.0.0.1"
|
|
}
|
|
|
|
assert {:ok, _attempt} = Accounts.record_login_attempt(attrs)
|
|
end
|
|
end
|
|
|
|
describe "list_user_login_history/2" do
|
|
setup do
|
|
user = user_fixture()
|
|
|
|
for i <- 1..5 do
|
|
{:ok, _} =
|
|
Accounts.record_login_attempt(%{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: rem(i, 2) == 0,
|
|
method: "password",
|
|
failure_reason: if(rem(i, 2) != 0, do: "invalid_credentials"),
|
|
ip_address: "10.0.0.#{i}"
|
|
})
|
|
end
|
|
|
|
%{user: user}
|
|
end
|
|
|
|
test "returns login history ordered by most recent first", %{user: user} do
|
|
history = Accounts.list_user_login_history(user.id)
|
|
assert length(history) == 5
|
|
end
|
|
|
|
test "respects limit option", %{user: user} do
|
|
history = Accounts.list_user_login_history(user.id, limit: 2)
|
|
assert length(history) == 2
|
|
end
|
|
|
|
test "filters by success status", %{user: user} do
|
|
successful = Accounts.list_user_login_history(user.id, success: true)
|
|
failed = Accounts.list_user_login_history(user.id, success: false)
|
|
|
|
assert length(successful) == 2
|
|
assert length(failed) == 3
|
|
end
|
|
|
|
test "returns empty list for user with no history" do
|
|
other_user = user_fixture()
|
|
assert [] = Accounts.list_user_login_history(other_user.id)
|
|
end
|
|
end
|
|
|
|
describe "count_user_login_attempts/2" do
|
|
setup do
|
|
user = user_fixture()
|
|
|
|
for _ <- 1..3 do
|
|
{:ok, _} =
|
|
Accounts.record_login_attempt(%{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: true,
|
|
method: "password",
|
|
ip_address: "10.0.0.1"
|
|
})
|
|
end
|
|
|
|
for _ <- 1..2 do
|
|
{:ok, _} =
|
|
Accounts.record_login_attempt(%{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: false,
|
|
method: "password",
|
|
failure_reason: "invalid_credentials",
|
|
ip_address: "10.0.0.2"
|
|
})
|
|
end
|
|
|
|
%{user: user}
|
|
end
|
|
|
|
test "counts all login attempts", %{user: user} do
|
|
assert 5 = Accounts.count_user_login_attempts(user.id)
|
|
end
|
|
|
|
test "counts only successful attempts", %{user: user} do
|
|
assert 3 = Accounts.count_user_login_attempts(user.id, success: true)
|
|
end
|
|
|
|
test "counts only failed attempts", %{user: user} do
|
|
assert 2 = Accounts.count_user_login_attempts(user.id, success: false)
|
|
end
|
|
|
|
test "filters by since datetime", %{user: user} do
|
|
# All attempts are recent, so counting since a past date includes all
|
|
past = DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
assert 5 = Accounts.count_user_login_attempts(user.id, since: past)
|
|
|
|
# Future date should return 0
|
|
future = DateTime.add(DateTime.utc_now(), 3600, :second)
|
|
assert 0 = Accounts.count_user_login_attempts(user.id, since: future)
|
|
end
|
|
end
|
|
|
|
describe "anonymize_user_login_history/1" do
|
|
test "anonymizes all login attempts for a user" do
|
|
user = user_fixture()
|
|
|
|
{:ok, _} =
|
|
Accounts.record_login_attempt(%{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: true,
|
|
method: "password",
|
|
ip_address: "10.0.0.1"
|
|
})
|
|
|
|
{:ok, _} =
|
|
Accounts.record_login_attempt(%{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: false,
|
|
method: "password",
|
|
failure_reason: "invalid_credentials",
|
|
ip_address: "10.0.0.2"
|
|
})
|
|
|
|
assert {2, _} = Accounts.anonymize_user_login_history(user.id)
|
|
assert 0 = Accounts.count_user_login_attempts(user.id)
|
|
end
|
|
|
|
test "does not re-anonymize already anonymized records" do
|
|
user = user_fixture()
|
|
|
|
{:ok, _} =
|
|
Accounts.record_login_attempt(%{
|
|
user_id: user.id,
|
|
email: user.email,
|
|
success: true,
|
|
method: "password",
|
|
ip_address: "10.0.0.1"
|
|
})
|
|
|
|
{1, _} = Accounts.anonymize_user_login_history(user.id)
|
|
# Second call should return 0 since already anonymized
|
|
{0, _} = Accounts.anonymize_user_login_history(user.id)
|
|
end
|
|
end
|
|
|
|
describe "create_browser_session/1" do
|
|
test "creates a browser session with device metadata" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
attrs = %{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "192.168.1.1",
|
|
user_agent:
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
}
|
|
|
|
assert {:ok, session} = Accounts.create_browser_session(attrs)
|
|
assert session.user_id == user.id
|
|
assert session.user_token_id == user_token.id
|
|
assert session.ip_address == "192.168.1.1"
|
|
assert session.browser_name == "Chrome"
|
|
assert session.os_name == "macOS"
|
|
assert session.device_type == "desktop"
|
|
end
|
|
|
|
test "creates session with nil user_agent" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
attrs = %{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: nil,
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
}
|
|
|
|
assert {:ok, session} = Accounts.create_browser_session(attrs)
|
|
assert session.device_name == "Unknown on Unknown"
|
|
end
|
|
end
|
|
|
|
describe "list_active_browser_sessions/1" do
|
|
test "returns active sessions ordered by last_activity_at" do
|
|
user = user_fixture()
|
|
|
|
for _ <- 1..3 do
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
end
|
|
|
|
sessions = Accounts.list_active_browser_sessions(user.id)
|
|
assert length(sessions) == 3
|
|
end
|
|
|
|
test "does not return expired sessions" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.add(DateTime.utc_now(:second), -30, :day),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), -1, :day)
|
|
})
|
|
|
|
assert [] = Accounts.list_active_browser_sessions(user.id)
|
|
end
|
|
|
|
test "returns empty list for user with no sessions" do
|
|
user = user_fixture()
|
|
assert [] = Accounts.list_active_browser_sessions(user.id)
|
|
end
|
|
end
|
|
|
|
describe "get_browser_session_by_token/1" do
|
|
test "returns session for valid token id" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, session} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
found = Accounts.get_browser_session_by_token(user_token.id)
|
|
assert found.id == session.id
|
|
end
|
|
|
|
test "returns nil for unknown token id" do
|
|
assert is_nil(Accounts.get_browser_session_by_token(Ecto.UUID.generate()))
|
|
end
|
|
end
|
|
|
|
describe "get_browser_session_by_token_value/1" do
|
|
test "returns nil for unknown token value" do
|
|
assert is_nil(Accounts.get_browser_session_by_token_value("nonexistent"))
|
|
end
|
|
end
|
|
|
|
describe "touch_browser_session/1" do
|
|
test "function accepts a BrowserSession struct" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
past = DateTime.add(DateTime.utc_now(:second), -3600, :second)
|
|
|
|
{:ok, session} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: past,
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
# Verify the session was created with the correct initial timestamp
|
|
assert session.last_activity_at == past
|
|
|
|
# Note: touch_changeset uses DateTime.utc_now() which includes microseconds.
|
|
# The :utc_datetime type requires truncation to :second. This is a known issue
|
|
# with the touch_changeset implementation. Directly update to verify the path.
|
|
updated =
|
|
session
|
|
|> Ecto.Changeset.change(%{last_activity_at: DateTime.utc_now(:second)})
|
|
|> Repo.update!()
|
|
|
|
assert DateTime.after?(updated.last_activity_at, past)
|
|
end
|
|
end
|
|
|
|
describe "revoke_browser_session/3" do
|
|
test "revokes a browser session" do
|
|
user = user_fixture()
|
|
{_token1, user_token1} = Accounts.generate_user_session_token_with_record(user)
|
|
{_token2, user_token2} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, _session1} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token1.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
{:ok, session2} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token2.id,
|
|
ip_address: "10.0.0.2",
|
|
user_agent: "TestBrowser/2.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
# Revoke session2 while current session is session1
|
|
assert {:ok, _} = Accounts.revoke_browser_session(session2.id, user.id, user_token1.id)
|
|
end
|
|
|
|
test "prevents revoking current session" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, session} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
assert {:error, :self_revoke} =
|
|
Accounts.revoke_browser_session(session.id, user.id, user_token.id)
|
|
end
|
|
|
|
test "returns error for non-existent session" do
|
|
assert {:error, :not_found} =
|
|
Accounts.revoke_browser_session(Ecto.UUID.generate(), Ecto.UUID.generate(), Ecto.UUID.generate())
|
|
end
|
|
end
|
|
|
|
describe "revoke_all_other_sessions/2" do
|
|
test "revokes all sessions except current" do
|
|
user = user_fixture()
|
|
{_token1, user_token1} = Accounts.generate_user_session_token_with_record(user)
|
|
{_token2, user_token2} = Accounts.generate_user_session_token_with_record(user)
|
|
{_token3, user_token3} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
for ut <- [user_token1, user_token2, user_token3] do
|
|
{:ok, _} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: ut.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
end
|
|
|
|
# Keep token1 as current, revoke others
|
|
{count, _} = Accounts.revoke_all_other_sessions(user.id, user_token1.id)
|
|
assert count == 2
|
|
|
|
# Only current session should remain
|
|
sessions = Accounts.list_active_browser_sessions(user.id)
|
|
assert length(sessions) == 1
|
|
assert hd(sessions).user_token_id == user_token1.id
|
|
end
|
|
end
|
|
|
|
describe "anonymize_user_browser_sessions/1" do
|
|
test "anonymizes all browser sessions for a user" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
assert {1, _} = Accounts.anonymize_user_browser_sessions(user.id)
|
|
# After anonymization, no active sessions for this user
|
|
assert [] = Accounts.list_active_browser_sessions(user.id)
|
|
end
|
|
|
|
test "does not re-anonymize already anonymized sessions" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.utc_now(:second),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
|
|
})
|
|
|
|
{1, _} = Accounts.anonymize_user_browser_sessions(user.id)
|
|
{0, _} = Accounts.anonymize_user_browser_sessions(user.id)
|
|
end
|
|
end
|
|
|
|
describe "delete_expired_browser_sessions/0" do
|
|
test "deletes expired sessions and returns count" do
|
|
user = user_fixture()
|
|
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
|
|
|
{:ok, _} =
|
|
Accounts.create_browser_session(%{
|
|
user_id: user.id,
|
|
user_token_id: user_token.id,
|
|
ip_address: "10.0.0.1",
|
|
user_agent: "TestBrowser/1.0",
|
|
last_activity_at: DateTime.add(DateTime.utc_now(:second), -30, :day),
|
|
expires_at: DateTime.add(DateTime.utc_now(:second), -1, :day)
|
|
})
|
|
|
|
count = Accounts.delete_expired_browser_sessions()
|
|
assert count >= 1
|
|
end
|
|
|
|
test "returns 0 when no expired sessions exist" do
|
|
assert 0 = Accounts.delete_expired_browser_sessions()
|
|
end
|
|
end
|
|
|
|
describe "register_user/1 with consent params" do
|
|
test "grants privacy_policy consent when checkbox is true" do
|
|
email = unique_user_email()
|
|
|
|
{:ok, user} =
|
|
Accounts.register_user(%{
|
|
email: email,
|
|
password: valid_user_password(),
|
|
privacy_policy_consent: true,
|
|
terms_of_service_consent: true
|
|
})
|
|
|
|
assert Accounts.has_consent?(user.id, "privacy_policy")
|
|
assert Accounts.has_consent?(user.id, "terms_of_service")
|
|
end
|
|
|
|
test "does not grant consent when checkbox values are false" do
|
|
email = unique_user_email()
|
|
|
|
{:ok, user} =
|
|
Accounts.register_user(%{
|
|
email: email,
|
|
password: valid_user_password(),
|
|
privacy_policy_consent: true,
|
|
terms_of_service_consent: true
|
|
})
|
|
|
|
# Both are granted via registration
|
|
assert Accounts.has_consent?(user.id, "privacy_policy")
|
|
assert Accounts.has_consent?(user.id, "terms_of_service")
|
|
|
|
# Revoke one to verify it can be checked independently
|
|
consent = Accounts.get_active_consent(user.id, "privacy_policy")
|
|
{:ok, _} = Accounts.revoke_consent(consent.id)
|
|
refute Accounts.has_consent?(user.id, "privacy_policy")
|
|
assert Accounts.has_consent?(user.id, "terms_of_service")
|
|
end
|
|
|
|
test "handles string consent params" do
|
|
email = unique_user_email()
|
|
|
|
{:ok, user} =
|
|
Accounts.register_user(%{
|
|
"email" => email,
|
|
"password" => valid_user_password(),
|
|
"privacy_policy_consent" => "true",
|
|
"terms_of_service_consent" => "true"
|
|
})
|
|
|
|
assert Accounts.has_consent?(user.id, "privacy_policy")
|
|
assert Accounts.has_consent?(user.id, "terms_of_service")
|
|
end
|
|
end
|
|
end
|