separate account settings from shop settings
All checks were successful
deploy / deploy (push) Successful in 3m28s
All checks were successful
deploy / deploy (push) Successful in 3m28s
- Create dedicated /admin/account page for user account management - Move email, password, and 2FA settings from /admin/settings - Add Account link to top of admin sidebar navigation - Add TOTP-based two-factor authentication with NimbleTOTP - Add TOTP verification LiveView for login flow - Add AccountController for TOTP session management - Remove Advanced section from settings (duplicated in dev tools) - Remove user email from sidebar footer (replaced by Account link) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -466,4 +466,104 @@ defmodule Berrypod.AccountsTest do
|
||||
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
||||
end
|
||||
end
|
||||
|
||||
describe "TOTP 2FA" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "generate_totp_secret/1 returns a secret and URI", %{user: user} do
|
||||
{secret, uri} = Accounts.generate_totp_secret(user)
|
||||
|
||||
assert is_binary(secret)
|
||||
assert byte_size(secret) == 20
|
||||
assert String.starts_with?(uri, "otpauth://totp/Berrypod:")
|
||||
assert uri =~ user.email
|
||||
end
|
||||
|
||||
test "totp_uri/2 regenerates the same URI format", %{user: user} do
|
||||
{secret, original_uri} = Accounts.generate_totp_secret(user)
|
||||
regenerated_uri = Accounts.totp_uri(user, secret)
|
||||
|
||||
assert original_uri == regenerated_uri
|
||||
end
|
||||
|
||||
test "enable_totp/3 enables TOTP with valid code", %{user: user} do
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
|
||||
assert {:ok, updated_user, backup_codes} = Accounts.enable_totp(user, secret, code)
|
||||
assert updated_user.totp_enabled_at
|
||||
assert updated_user.totp_secret_encrypted
|
||||
assert updated_user.totp_backup_codes_encrypted
|
||||
assert length(backup_codes) == 8
|
||||
assert Enum.all?(backup_codes, &(String.length(&1) == 8))
|
||||
end
|
||||
|
||||
test "enable_totp/3 fails with invalid code", %{user: user} do
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
|
||||
assert {:error, :invalid_code} = Accounts.enable_totp(user, secret, "000000")
|
||||
end
|
||||
|
||||
test "totp_enabled?/1 returns true when TOTP is enabled", %{user: user} do
|
||||
refute Accounts.totp_enabled?(user)
|
||||
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
{:ok, updated_user, _backup_codes} = Accounts.enable_totp(user, secret, code)
|
||||
|
||||
assert Accounts.totp_enabled?(updated_user)
|
||||
end
|
||||
|
||||
test "verify_totp/2 accepts valid TOTP code", %{user: user} do
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
|
||||
|
||||
# Generate a new valid code for verification
|
||||
new_code = NimbleTOTP.verification_code(secret)
|
||||
assert :ok = Accounts.verify_totp(user_with_totp, new_code)
|
||||
end
|
||||
|
||||
test "verify_totp/2 rejects invalid TOTP code", %{user: user} do
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
|
||||
|
||||
assert :error = Accounts.verify_totp(user_with_totp, "000000")
|
||||
end
|
||||
|
||||
test "verify_totp/2 accepts valid backup code and consumes it", %{user: user} do
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
{:ok, user_with_totp, backup_codes} = Accounts.enable_totp(user, secret, code)
|
||||
|
||||
[first_backup | _rest] = backup_codes
|
||||
|
||||
# First use should succeed
|
||||
assert :ok = Accounts.verify_totp(user_with_totp, first_backup)
|
||||
|
||||
# Reload user to get updated backup codes
|
||||
updated_user = Accounts.get_user!(user.id)
|
||||
|
||||
# Second use of same code should fail
|
||||
assert :error = Accounts.verify_totp(updated_user, first_backup)
|
||||
end
|
||||
|
||||
test "disable_totp/1 removes TOTP configuration", %{user: user} do
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
|
||||
|
||||
assert Accounts.totp_enabled?(user_with_totp)
|
||||
|
||||
{:ok, disabled_user} = Accounts.disable_totp(user_with_totp)
|
||||
|
||||
refute Accounts.totp_enabled?(disabled_user)
|
||||
refute disabled_user.totp_secret_encrypted
|
||||
refute disabled_user.totp_backup_codes_encrypted
|
||||
refute disabled_user.totp_enabled_at
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
101
test/berrypod/rate_limit_test.exs
Normal file
101
test/berrypod/rate_limit_test.exs
Normal file
@@ -0,0 +1,101 @@
|
||||
defmodule Berrypod.RateLimitTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Berrypod.RateLimit
|
||||
|
||||
describe "check_login/1" do
|
||||
test "allows requests within limit" do
|
||||
ip = {192, 168, 1, unique_integer()}
|
||||
|
||||
for _i <- 1..5 do
|
||||
assert :ok = RateLimit.check_login(ip)
|
||||
end
|
||||
end
|
||||
|
||||
test "blocks requests exceeding limit" do
|
||||
ip = {192, 168, 2, unique_integer()}
|
||||
|
||||
for _i <- 1..5 do
|
||||
assert :ok = RateLimit.check_login(ip)
|
||||
end
|
||||
|
||||
assert {:error, retry_after} = RateLimit.check_login(ip)
|
||||
assert is_integer(retry_after)
|
||||
assert retry_after > 0
|
||||
end
|
||||
|
||||
test "handles string IP addresses" do
|
||||
ip = "10.0.0.#{unique_integer()}"
|
||||
|
||||
assert :ok = RateLimit.check_login(ip)
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_magic_link/1" do
|
||||
test "allows requests within limit" do
|
||||
email = "test#{unique_integer()}@example.com"
|
||||
|
||||
for _i <- 1..3 do
|
||||
assert :ok = RateLimit.check_magic_link(email)
|
||||
end
|
||||
end
|
||||
|
||||
test "blocks requests exceeding limit" do
|
||||
email = "blocked#{unique_integer()}@example.com"
|
||||
|
||||
for _i <- 1..3 do
|
||||
assert :ok = RateLimit.check_magic_link(email)
|
||||
end
|
||||
|
||||
assert {:error, retry_after} = RateLimit.check_magic_link(email)
|
||||
assert is_integer(retry_after)
|
||||
assert retry_after > 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_newsletter/1" do
|
||||
test "allows requests within limit" do
|
||||
ip = {172, 16, 1, unique_integer()}
|
||||
|
||||
for _i <- 1..10 do
|
||||
assert :ok = RateLimit.check_newsletter(ip)
|
||||
end
|
||||
end
|
||||
|
||||
test "blocks requests exceeding limit" do
|
||||
ip = {172, 16, 2, unique_integer()}
|
||||
|
||||
for _i <- 1..10 do
|
||||
assert :ok = RateLimit.check_newsletter(ip)
|
||||
end
|
||||
|
||||
assert {:error, retry_after} = RateLimit.check_newsletter(ip)
|
||||
assert is_integer(retry_after)
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_api/1" do
|
||||
test "allows requests within limit" do
|
||||
ip = {10, 10, 1, unique_integer()}
|
||||
|
||||
for _i <- 1..60 do
|
||||
assert :ok = RateLimit.check_api(ip)
|
||||
end
|
||||
end
|
||||
|
||||
test "blocks requests exceeding limit" do
|
||||
ip = {10, 10, 2, unique_integer()}
|
||||
|
||||
for _i <- 1..60 do
|
||||
assert :ok = RateLimit.check_api(ip)
|
||||
end
|
||||
|
||||
assert {:error, retry_after} = RateLimit.check_api(ip)
|
||||
assert is_integer(retry_after)
|
||||
end
|
||||
end
|
||||
|
||||
defp unique_integer do
|
||||
System.unique_integer([:positive]) |> rem(256)
|
||||
end
|
||||
end
|
||||
123
test/berrypod_web/live/admin/account_test.exs
Normal file
123
test/berrypod_web/live/admin/account_test.exs
Normal file
@@ -0,0 +1,123 @@
|
||||
defmodule BerrypodWeb.Admin.AccountTest do
|
||||
use BerrypodWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
|
||||
alias Berrypod.Accounts
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
%{user: user}
|
||||
end
|
||||
|
||||
describe "unauthenticated" do
|
||||
test "redirects to login", %{conn: conn} do
|
||||
{:error, redirect} = live(conn, ~p"/admin/account")
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "account page" do
|
||||
setup %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
test "renders email and password forms", %{conn: conn, user: user} do
|
||||
{:ok, view, html} = live(conn, ~p"/admin/account")
|
||||
|
||||
assert html =~ "Account"
|
||||
assert html =~ user.email
|
||||
assert has_element?(view, "#email_form")
|
||||
assert has_element?(view, "#password_form")
|
||||
end
|
||||
|
||||
test "validates email change", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/account")
|
||||
|
||||
result =
|
||||
view
|
||||
|> element("#email_form")
|
||||
|> render_change(%{"user" => %{"email" => "with spaces"}})
|
||||
|
||||
assert result =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
|
||||
test "submits email change", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/account")
|
||||
|
||||
result =
|
||||
view
|
||||
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|
||||
|> render_submit()
|
||||
|
||||
assert result =~ "A link to confirm your email"
|
||||
end
|
||||
|
||||
test "validates password", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/account")
|
||||
|
||||
result =
|
||||
view
|
||||
|> element("#password_form")
|
||||
|> render_change(%{
|
||||
"user" => %{
|
||||
"password" => "short",
|
||||
"password_confirmation" => "mismatch"
|
||||
}
|
||||
})
|
||||
|
||||
assert result =~ "should be at least 12 character(s)"
|
||||
end
|
||||
|
||||
test "submits valid password change", %{conn: conn, user: user} do
|
||||
new_password = valid_user_password()
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/account")
|
||||
|
||||
form =
|
||||
form(view, "#password_form", %{
|
||||
"user" => %{
|
||||
"email" => user.email,
|
||||
"password" => new_password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
render_submit(form)
|
||||
new_password_conn = follow_trigger_action(form, conn)
|
||||
|
||||
assert redirected_to(new_password_conn) == ~p"/admin/account"
|
||||
assert Accounts.get_user_by_email_and_password(user.email, new_password)
|
||||
end
|
||||
end
|
||||
|
||||
describe "two-factor authentication" do
|
||||
setup %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
test "shows 2FA section", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/account")
|
||||
|
||||
assert html =~ "Two-factor authentication"
|
||||
assert html =~ "Off"
|
||||
assert html =~ "Enable 2FA"
|
||||
end
|
||||
|
||||
test "shows enabled state when TOTP is enabled", %{conn: conn, user: user} do
|
||||
# Enable TOTP for the user
|
||||
secret = NimbleTOTP.secret()
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
{:ok, _user, _codes} = Accounts.enable_totp(user, secret, code)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/account")
|
||||
|
||||
assert html =~ "Two-factor authentication"
|
||||
assert html =~ "Enabled"
|
||||
assert html =~ "Disable 2FA"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,12 +37,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
|
||||
refute has_element?(view, ~s(a.active[href="/admin/orders"]))
|
||||
end
|
||||
|
||||
test "shows user email in sidebar", %{conn: conn, user: user} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/orders")
|
||||
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "shows shop and log out links", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/orders")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
import Berrypod.AccountsFixtures
|
||||
import Berrypod.ProductsFixtures
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Settings
|
||||
|
||||
setup do
|
||||
@@ -166,80 +165,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "account section" do
|
||||
setup %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
test "renders email and password forms", %{conn: conn, user: user} do
|
||||
{:ok, view, html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
assert html =~ "Account"
|
||||
assert html =~ user.email
|
||||
assert has_element?(view, "#email_form")
|
||||
assert has_element?(view, "#password_form")
|
||||
end
|
||||
|
||||
test "validates email change", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
result =
|
||||
view
|
||||
|> element("#email_form")
|
||||
|> render_change(%{"user" => %{"email" => "with spaces"}})
|
||||
|
||||
assert result =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
|
||||
test "submits email change", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
result =
|
||||
view
|
||||
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|
||||
|> render_submit()
|
||||
|
||||
assert result =~ "A link to confirm your email"
|
||||
end
|
||||
|
||||
test "validates password", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
result =
|
||||
view
|
||||
|> element("#password_form")
|
||||
|> render_change(%{
|
||||
"user" => %{
|
||||
"password" => "short",
|
||||
"password_confirmation" => "mismatch"
|
||||
}
|
||||
})
|
||||
|
||||
assert result =~ "should be at least 12 character(s)"
|
||||
end
|
||||
|
||||
test "submits valid password change", %{conn: conn, user: user} do
|
||||
new_password = valid_user_password()
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
form =
|
||||
form(view, "#password_form", %{
|
||||
"user" => %{
|
||||
"email" => user.email,
|
||||
"password" => new_password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
render_submit(form)
|
||||
new_password_conn = follow_trigger_action(form, conn)
|
||||
|
||||
assert redirected_to(new_password_conn) == ~p"/admin/settings"
|
||||
assert Accounts.get_user_by_email_and_password(user.email, new_password)
|
||||
end
|
||||
end
|
||||
|
||||
describe "from address" do
|
||||
setup %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
@@ -256,27 +181,12 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
test "saves from address", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|
||||
|> render_submit()
|
||||
view
|
||||
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, ".admin-inline-feedback-saved")
|
||||
assert Settings.get_setting("email_from_address") == "shop@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "advanced section" do
|
||||
setup %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
%{conn: conn}
|
||||
end
|
||||
|
||||
test "shows links to system tools", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
assert has_element?(view, ~s(a[href="/admin/dashboard"]), "System dashboard")
|
||||
assert has_element?(view, ~s(a[href="/admin/errors"]), "Error tracker")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user