towerops/test/towerops_web/controllers/user_sudo_controller_test.exs
Graham McIntire 264154a3d8
feat: implement sudo mode MFA-only verification controller
- Add UserSudoController with GET and POST /users/sudo/verify routes
- Create verify.html.heex template for TOTP verification form
- Only accept TOTP codes (6 numeric digits), reject recovery codes
- Update grant_sudo_mode to set authenticated_at virtual field
- Exclude /users/sudo paths from return_to overwriting
- Add comprehensive controller tests (12 test cases)
- Verify redirect behavior, error handling, and session management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 14:34:17 -06:00

165 lines
5.7 KiB
Elixir

defmodule ToweropsWeb.UserSudoControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Accounts
setup do
# Create user with TOTP enabled (required for sudo mode)
user = user_fixture(enable_totp: true)
%{user: user}
end
describe "GET /users/sudo/verify" do
test "renders sudo verification page for authenticated user", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> get(~p"/users/sudo/verify")
response = html_response(conn, 200)
assert response =~ "Re-authenticate"
assert response =~ "Enter the 6-digit code from your authenticator app"
end
test "redirects unauthenticated users to login", %{conn: conn} do
conn = get(conn, ~p"/users/sudo/verify")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page."
end
test "preserves return_to path from session", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> init_test_session(user_return_to: "/users/settings")
|> get(~p"/users/sudo/verify")
assert html_response(conn, 200) =~ "Re-authenticate"
assert get_session(conn, :user_return_to) == "/users/settings"
end
end
describe "POST /users/sudo/verify" do
test "grants sudo mode and redirects on valid TOTP code", %{conn: conn, user: user} do
# Get valid TOTP code for user
code = NimbleTOTP.verification_code(user.totp_secret)
conn =
conn
|> log_in_user(user)
|> init_test_session(user_return_to: "/users/settings")
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}})
# Should redirect to return_to path (implies sudo mode was granted)
assert redirected_to(conn) == "/users/settings"
# Should clear return_to from session
assert get_session(conn, :user_return_to) == nil
# Should update last_sudo_at timestamp
updated_user = Accounts.get_user!(user.id)
assert updated_user.last_sudo_at
assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1
end
test "grants sudo mode and redirects to /orgs if no return_to", %{conn: conn, user: user} do
code = NimbleTOTP.verification_code(user.totp_secret)
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}})
# Should redirect to /orgs (implies sudo mode was granted)
assert redirected_to(conn) == ~p"/orgs"
# Should update last_sudo_at timestamp
updated_user = Accounts.get_user!(user.id)
assert updated_user.last_sudo_at
assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1
end
test "shows error on invalid TOTP code", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}})
response = html_response(conn, 200)
assert response =~ "Invalid authentication code"
assert response =~ "Re-authenticate"
# Should not grant sudo mode
user = Accounts.get_user!(user.id)
refute Accounts.sudo_mode?(user)
end
test "shows error when recovery code is attempted", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "ABCD-EFGH"}})
response = html_response(conn, 200)
assert response =~ "Recovery codes are not allowed for sudo mode verification"
assert response =~ "Re-authenticate"
# Should not grant sudo mode
user = Accounts.get_user!(user.id)
refute Accounts.sudo_mode?(user)
end
test "shows error when TOTP code is too long", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "1234567"}})
response = html_response(conn, 200)
assert response =~ "Recovery codes are not allowed for sudo mode verification"
assert response =~ "Re-authenticate"
end
test "shows error when TOTP code contains letters", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "12AB34"}})
response = html_response(conn, 200)
assert response =~ "Recovery codes are not allowed for sudo mode verification"
assert response =~ "Re-authenticate"
end
test "redirects unauthenticated users to login", %{conn: conn} do
conn = post(conn, ~p"/users/sudo/verify", %{"user" => %{"totp_code" => "123456"}})
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page."
end
test "preserves return_to path on verification failure", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> init_test_session(user_return_to: "/users/settings")
|> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}})
assert html_response(conn, 200) =~ "Invalid authentication code"
# return_to should be preserved for next attempt
assert get_session(conn, :user_return_to) == "/users/settings"
end
test "handles missing TOTP code parameter", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> post(~p"/users/sudo/verify", %{"user" => %{}})
response = html_response(conn, 200)
assert response =~ "Re-authenticate"
# Should render form again (no crash)
end
end
end