towerops/test/towerops/accounts_test.exs
Graham McIntire 0ea54b63db
perf(tests): disable TOTP by default in fixtures for faster tests
**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
2026-03-06 07:21:52 -06:00

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