defmodule MvWeb.AuthControllerTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest import Phoenix.ConnTest import ExUnit.CaptureLog alias Mv.Membership # Helper to create an unauthenticated conn (preserves sandbox metadata) defp build_unauthenticated_conn(authenticated_conn) do # Create new conn but preserve sandbox metadata for database access new_conn = build_conn() |> init_test_session(%{}) |> fetch_flash() # Copy sandbox metadata from authenticated conn if authenticated_conn.private[:ecto_sandbox] do Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox]) else new_conn end end # Basic UI tests test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/sign-in") assert html_response(conn, 200) =~ "Sign in" end @tag role: :unauthenticated test "GET /sign-in returns 200 and renders page (exercises AuthOverrides and layout)", %{ conn: conn } do {:ok, _view, html} = live(conn, ~p"/sign-in") assert html =~ "Sign in" # Public header (logo) from Layouts.app unauthenticated branch assert html =~ "mila.svg" or html =~ "Mila Logo" end test "GET /sign-out redirects to home", %{conn: authenticated_conn} do conn = conn_with_oidc_user(authenticated_conn) conn = get(conn, ~p"/sign-out") assert redirected_to(conn) == ~p"/" end # Password authentication (LiveView) test "password user can sign in with valid credentials via LiveView", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) _user = create_test_user(%{ email: "password@example.com", password: "secret123", oidc_id: nil }) {:ok, view, _html} = live(conn, "/sign-in") {:error, {:redirect, %{to: to}}} = view |> form("#user-password-sign-in-with-password", user: %{email: "password@example.com", password: "secret123"} ) |> render_submit() assert to =~ "/auth/user/password/sign_in_with_token" end test "password user with invalid credentials shows error via LiveView", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) _user = create_test_user(%{ email: "test@example.com", password: "correct_password", oidc_id: nil }) {:ok, view, _html} = live(conn, "/sign-in") html = view |> form("#user-password-sign-in-with-password", user: %{email: "test@example.com", password: "wrong_password"} ) |> render_submit() assert html =~ "Email or password was incorrect" end test "password user with non-existent email shows error via LiveView", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) {:ok, view, _html} = live(conn, "/sign-in") html = view |> form("#user-password-sign-in-with-password", user: %{email: "nonexistent@example.com", password: "anypassword"} ) |> render_submit() assert html =~ "Email or password was incorrect" end # Registration (LiveView) test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) {:ok, view, _html} = live(conn, "/register") {:error, {:redirect, %{to: to}}} = view |> form("#user-password-register-with-password-wrapper form", user: %{email: "newuser@example.com", password: "newpassword123"} ) |> render_submit() assert to =~ "/auth/user/password/sign_in_with_token" end test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) _user = create_test_user(%{ email: "existing@example.com", password: "secret123", oidc_id: nil }) {:ok, view, _html} = live(conn, "/register") html = view |> form("#user-password-register-with-password-wrapper form", user: %{email: "existing@example.com", password: "anotherpassword"} ) |> render_submit() assert html =~ "has already been taken" end test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) {:ok, view, _html} = live(conn, "/register") html = view |> form("#user-password-register-with-password-wrapper form", user: %{email: "weakpass@example.com", password: "123"} ) |> render_submit() assert html =~ "length must be greater than or equal to 8" end test "when registration is disabled, sign-in page does not show Need an account? toggle", %{ conn: authenticated_conn } do {:ok, settings} = Membership.get_settings() original = Map.get(settings, :registration_enabled, true) {:ok, _} = Membership.update_settings(settings, %{registration_enabled: false}) try do conn = build_unauthenticated_conn(authenticated_conn) {:ok, _view, html} = live(conn, ~p"/sign-in") refute html =~ "Need an account?" after {:ok, s} = Membership.get_settings() Membership.update_settings(s, %{registration_enabled: original}) end end # Access control test "unauthenticated user accessing protected route gets redirected to sign-in", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/members") assert redirected_to(conn) == ~p"/sign-in" end test "authenticated user can access protected route", %{conn: authenticated_conn} do conn = conn_with_oidc_user(authenticated_conn) conn = get(conn, ~p"/members") assert conn.status == 200 end test "password authenticated user can access protected route via LiveView", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) _user = create_test_user(%{ email: "auth@example.com", password: "secret123", oidc_id: nil }) {:ok, view, _html} = live(conn, "/sign-in") {:error, {:redirect, %{to: to}}} = view |> form("#user-password-sign-in-with-password", user: %{email: "auth@example.com", password: "secret123"} ) |> render_submit() assert to =~ "/auth/user/password/sign_in_with_token" # After login, user is redirected to /auth/user/password/sign_in_with_token. # Session handling for protected routes should be tested in integration or E2E tests. end # Edge cases test "user with nil oidc_id can still sign in with password via LiveView", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) _user = create_test_user(%{ email: "nil_oidc@example.com", password: "secret123", oidc_id: nil }) {:ok, view, _html} = live(conn, "/sign-in") {:error, {:redirect, %{to: to}}} = view |> form("#user-password-sign-in-with-password", user: %{email: "nil_oidc@example.com", password: "secret123"} ) |> render_submit() assert to =~ "/auth/user/password/sign_in_with_token" end test "user with empty string oidc_id is handled correctly via LiveView", %{ conn: authenticated_conn } do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) _user = create_test_user(%{ email: "empty_oidc@example.com", password: "secret123", oidc_id: "" }) {:ok, view, _html} = live(conn, "/sign-in") {:error, {:redirect, %{to: to}}} = view |> form("#user-password-sign-in-with-password", user: %{email: "empty_oidc@example.com", password: "secret123"} ) |> render_submit() assert to =~ "/auth/user/password/sign_in_with_token" end describe "when OIDC-only is enabled" do setup %{conn: authenticated_conn} do {:ok, settings} = Membership.get_settings() original_oidc_only = Map.get(settings, :oidc_only, false) {:ok, _} = Membership.update_settings(settings, %{oidc_only: true}) conn = build_unauthenticated_conn(authenticated_conn) {:ok, conn: conn, original_oidc_only: original_oidc_only} end test "password sign-in is rejected and redirects to sign-in with error", %{ conn: conn, original_oidc_only: original } do try do _user = create_test_user(%{ email: "password@example.com", password: "secret123", oidc_id: nil }) {:ok, view, _html} = live(conn, "/sign-in") result = view |> form("#user-password-sign-in-with-password", user: %{email: "password@example.com", password: "secret123"} ) |> render_submit() # When OIDC-only is enabled, password sign-in must not succeed (no redirect to sign_in_with_token). case result do {:error, {:redirect, %{to: to}}} -> refute to =~ "sign_in_with_token", "Expected password sign-in to be rejected when OIDC-only, got redirect to: #{to}" _ -> # LiveView re-rendered (e.g. with flash error) instead of redirecting to success :ok end after {:ok, s} = Membership.get_settings() Membership.update_settings(s, %{oidc_only: original}) end end end describe "GET /sign-in when OIDC-only" do test "redirects to OIDC flow when OIDC-only and OIDC are configured", %{ conn: authenticated_conn } do {:ok, settings} = Membership.get_settings() prev = %{ oidc_only: settings.oidc_only, oidc_client_id: settings.oidc_client_id, oidc_base_url: settings.oidc_base_url, oidc_redirect_uri: settings.oidc_redirect_uri } {:ok, _} = Membership.update_settings(settings, %{ oidc_only: true, oidc_client_id: "test-client", oidc_base_url: "https://idp.example.com", oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback", oidc_client_secret: "test-secret" }) try do conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/sign-in") assert redirected_to(conn) =~ "/auth/user/oidc" after {:ok, s} = Membership.get_settings() Membership.update_settings(s, prev) end end test "returns 200 when OIDC-only but OIDC not configured", %{conn: authenticated_conn} do {:ok, settings} = Membership.get_settings() original_oidc_only = Map.get(settings, :oidc_only, false) {:ok, _} = Membership.update_settings(settings, %{oidc_only: true}) try do conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/sign-in") assert conn.status == 200 after {:ok, s} = Membership.get_settings() Membership.update_settings(s, %{oidc_only: original_oidc_only}) end end test "returns 200 when OIDC-only is disabled", %{conn: authenticated_conn} do conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/sign-in") assert conn.status == 200 end end # OIDC/Rauthy error handling tests describe "handle_oidc_failure/2" do test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) # Create a mock Assent.ServerUnreachableError struct with required fields error = %Assent.ServerUnreachableError{ http_adapter: Assent.HTTPAdapter.Finch, request_url: "https://auth.example.com/callback?token=secret123", reason: %Mint.TransportError{reason: :econnrefused} } conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error) assert redirected_to(conn) == ~p"/sign-in" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "The authentication server is currently unavailable. Please try again later." end test "Assent.InvalidResponseError redirects to sign-in with error flash", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) # Create a mock Assent.InvalidResponseError struct with required field # InvalidResponseError only has :response field (HTTPResponse struct) error = %Assent.InvalidResponseError{ response: %Assent.HTTPAdapter.HTTPResponse{ status: 400, headers: [], body: "invalid_request" } } conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error) assert redirected_to(conn) == ~p"/sign-in" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Authentication configuration error. Please contact the administrator." end test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) unknown_reason = :oops conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason) assert redirected_to(conn) == ~p"/sign-in" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to authenticate with OIDC. Please try again." end end # Logging security tests - ensure no sensitive data is logged describe "failure/3 logging security" do test "does not log full URL with query params for Assent.ServerUnreachableError", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) error = %Assent.ServerUnreachableError{ http_adapter: Assent.HTTPAdapter.Finch, request_url: "https://auth.example.com/callback?token=secret123&code=abc456", reason: %Mint.TransportError{reason: :econnrefused} } log = capture_log(fn -> MvWeb.AuthController.failure(conn, {:oidc, :callback}, error) end) # Should log redacted URL (only scheme and host) assert log =~ "https://auth.example.com" # Should NOT log query parameters or tokens refute log =~ "token=secret123" refute log =~ "code=abc456" refute log =~ "callback?token" end test "does not log sensitive data for Assent.InvalidResponseError", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) error = %Assent.InvalidResponseError{ response: %Assent.HTTPAdapter.HTTPResponse{ status: 400, headers: [], body: "invalid_request" } } log = capture_log(fn -> MvWeb.AuthController.failure(conn, {:oidc, :callback}, error) end) # Should log error type but not full error details assert log =~ "Authentication failure" assert log =~ "oidc" # Should not log full error struct with inspect refute log =~ "Assent.InvalidResponseError" end test "does not log full reason for unknown OIDC errors", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) # Simulate an error that might contain sensitive data error_with_sensitive_data = %{ token: "secret_token_123", url: "https://example.com/callback?access_token=abc123", error: :something_went_wrong } log = capture_log(fn -> MvWeb.AuthController.failure(conn, {:oidc, :callback}, error_with_sensitive_data) end) # Should log error type but not full error details assert log =~ "Authentication failure" assert log =~ "oidc" # Should NOT log sensitive data refute log =~ "secret_token_123" refute log =~ "access_token=abc123" refute log =~ "callback?access_token" end test "logs full reason for non-OIDC activities (password auth)", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) reason = %AshAuthentication.Errors.AuthenticationFailed{ caused_by: %Ash.Error.Forbidden{errors: []} } log = capture_log(fn -> MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason) end) # For non-OIDC activities, full reason is safe to log assert log =~ "Authentication failure" assert log =~ "password" assert log =~ "AuthenticationFailed" end end end