From ad00e8e7b6a543f0bb3c9d27f9925cc912be5c9d Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 29 Jan 2026 23:56:12 +0100 Subject: [PATCH] test: add page permission tests and ConnCase role tags - ConnCase: add :read_only and :normal_user role tags for tests. - Add CheckPagePermission plug tests (unit + integration for member, read_only, normal_user, admin). Update permission_sets_test (refute "/" for own_data). - Profile navigation, global_settings, role_live, membership_fee_type: use users with role for "/" access; expect redirect for own_data on /settings and /admin/roles. --- .../mv/authorization/permission_sets_test.exs | 3 +- .../mv_web/live/global_settings_live_test.exs | 18 +- .../membership_fee_type_live/form_test.exs | 13 +- .../membership_fee_type_live/index_test.exs | 7 +- test/mv_web/live/profile_navigation_test.exs | 66 +- test/mv_web/live/role_live_test.exs | 15 +- .../plugs/check_page_permission_test.exs | 867 ++++++++++++++++++ test/support/conn_case.ex | 12 + 8 files changed, 943 insertions(+), 58 deletions(-) create mode 100644 test/mv_web/plugs/check_page_permission_test.exs diff --git a/test/mv/authorization/permission_sets_test.exs b/test/mv/authorization/permission_sets_test.exs index dcd0680..5a00c45 100644 --- a/test/mv/authorization/permission_sets_test.exs +++ b/test/mv/authorization/permission_sets_test.exs @@ -127,7 +127,8 @@ defmodule Mv.Authorization.PermissionSetsTest do test "includes correct pages" do permissions = PermissionSets.get_permissions(:own_data) - assert "/" in permissions.pages + # Root "/" is not allowed for own_data (Mitglied is redirected to profile) + refute "/" in permissions.pages assert "/profile" in permissions.pages assert "/members/:id" in permissions.pages end diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index aabec7b..f217311 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -158,15 +158,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do end test "non-admin user does not see import section", %{conn: conn} do - # Create non-admin user (member role) + # Member (own_data) is redirected when accessing /settings (no page permission) member_user = Mv.Fixtures.user_with_role_fixture("own_data") conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user) - {:ok, _view, html} = live(conn, ~p"/settings") - - # Import section should not be visible - refute html =~ "Import Members" or html =~ "CSV Import" or - (html =~ "Import" and html =~ "CSV") + assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings") + assert to == "/users/#{member_user.id}" end end @@ -236,15 +233,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do end test "non-admin cannot start import", %{conn: conn} do - # Create non-admin user + # Member (own_data) is redirected when accessing /settings (no page permission) member_user = Mv.Fixtures.user_with_role_fixture("own_data") conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user) - {:ok, view, _html} = live(conn, ~p"/settings") - - # Since non-admin shouldn't see the section, we check that import section is not visible - html = render(view) - refute html =~ "Import Members" or html =~ "CSV Import" or html =~ "start_import" + assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings") + assert to == "/users/#{member_user.id}" end test "invalid CSV shows user-friendly error", %{conn: conn} do diff --git a/test/mv_web/live/membership_fee_type_live/form_test.exs b/test/mv_web/live/membership_fee_type_live/form_test.exs index 0902200..f0a21c7 100644 --- a/test/mv_web/live/membership_fee_type_live/form_test.exs +++ b/test/mv_web/live/membership_fee_type_live/form_test.exs @@ -11,17 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do require Ash.Query setup %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - # Create admin user - {:ok, user} = - Mv.Accounts.User - |> Ash.Changeset.for_create(:register_with_password, %{ - email: "admin#{System.unique_integer([:positive])}@mv.local", - password: "testpassword123" - }) - |> Ash.create(actor: system_actor) - + # User must have admin role (or normal_user) to access /membership_fee_types pages + user = Mv.Fixtures.user_with_role_fixture("admin") authenticated_conn = conn_with_password_user(conn, user) %{conn: authenticated_conn, user: user} end diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs index 38c81fb..7d2d4be 100644 --- a/test/mv_web/live/membership_fee_type_live/index_test.exs +++ b/test/mv_web/live/membership_fee_type_live/index_test.exs @@ -29,8 +29,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do |> Ash.create!(actor: admin_user) end - # Helper to create a member - # Uses admin actor from global setup to ensure authorization; falls back to system_actor when nil + # Helper to create a member. Requires actor (e.g. admin_user from setup); no fallback so + # missing-actor bugs are not masked in tests. defp create_member(attrs, actor) do default_attrs = %{ first_name: "Test", @@ -39,8 +39,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do } attrs = Map.merge(default_attrs, attrs) - effective_actor = actor || Mv.Helpers.SystemActor.get_system_actor() - {:ok, member} = Mv.Membership.create_member(attrs, actor: effective_actor) + {:ok, member} = Mv.Membership.create_member(attrs, actor: actor) member end diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index b104900..b8562cd 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -9,8 +9,8 @@ defmodule MvWeb.ProfileNavigationTest do describe "profile navigation" do test "clicking profile button redirects to current user profile", %{conn: conn} do - # Setup: Create and login a user - user = create_test_user(%{email: "test@example.com"}) + # User needs a role with page permission for "/" (e.g. admin) + user = Mv.Fixtures.user_with_role_fixture("admin") conn = conn_with_password_user(conn, user) {:ok, view, _html} = live(conn, "/") @@ -21,9 +21,18 @@ defmodule MvWeb.ProfileNavigationTest do assert_redirected(view, "/users/#{user.id}") end - test "profile navigation shows correct user data", %{conn: conn} do - # Setup: Create and login a user + test "profile navigation shows correct user data", %{conn: conn, actor: actor} do + # User with password (from create_test_user) and admin role so they can access "/" user = create_test_user(%{email: "test@example.com"}) + admin_role = Mv.Fixtures.role_fixture("admin") + + {:ok, user} = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update(actor: actor) + + user = Ash.load!(user, :role, domain: Mv.Accounts, actor: actor) conn = conn_with_password_user(conn, user) # Navigate to profile @@ -40,8 +49,8 @@ defmodule MvWeb.ProfileNavigationTest do describe "sidebar" do test "renders profile button with correct attributes", %{conn: conn} do - # Setup: Create and login a user - user = create_test_user(%{email: "test@example.com"}) + # User needs a role with page permission for "/" + user = Mv.Fixtures.user_with_role_fixture("admin") conn = conn_with_password_user(conn, user) {:ok, _view, html} = live(conn, "/") @@ -85,16 +94,27 @@ defmodule MvWeb.ProfileNavigationTest do }) |> Ash.create!(domain: Mv.Accounts, actor: actor) + # Assign role so user can access "/" (page permission) + admin_role = Mv.Fixtures.role_fixture("admin") + + {:ok, user_with_role} = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update(actor: actor) + + user_with_role = Ash.load!(user_with_role, :role, domain: Mv.Accounts, actor: actor) + # Login user via OIDC - conn = sign_in_user_via_oidc(conn, user) + conn = sign_in_user_via_oidc(conn, user_with_role) # Navigate to home and click profile {:ok, view, _html} = live(conn, "/") view |> element("a", "Profil") |> render_click() # Verify we're on the correct profile page with OIDC specific information - {:ok, _profile_view, html} = live(conn, "/users/#{user.id}") - assert html =~ to_string(user.email) + {:ok, _profile_view, html} = live(conn, "/users/#{user_with_role.id}") + assert html =~ to_string(user_with_role.email) # Password auth should be disabled for OIDC users assert html =~ "Not enabled" end @@ -103,14 +123,10 @@ defmodule MvWeb.ProfileNavigationTest do conn: conn, actor: actor } do - # Create password user - password_user = - create_test_user(%{ - email: "password2@example.com", - password: "test_password123" - }) + # Users need a role with page permission for "/" + password_user = Mv.Fixtures.user_with_role_fixture("admin") - # Create OIDC user + # Create OIDC user and assign admin role user_info = %{ "sub" => "oidc_789", "preferred_username" => "oidc@example.com" @@ -129,6 +145,17 @@ defmodule MvWeb.ProfileNavigationTest do }) |> Ash.create!(domain: Mv.Accounts, actor: actor) + admin_role = Mv.Fixtures.role_fixture("admin") + + {:ok, oidc_user_with_role} = + oidc_user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update(actor: actor) + + oidc_user_with_role = + Ash.load!(oidc_user_with_role, :role, domain: Mv.Accounts, actor: actor) + # Test with password user conn_password = conn_with_password_user(conn, password_user) {:ok, view_password, _html} = live(conn_password, "/") @@ -136,16 +163,17 @@ defmodule MvWeb.ProfileNavigationTest do assert_redirected(view_password, "/users/#{password_user.id}") # Test with OIDC user - conn_oidc = sign_in_user_via_oidc(conn, oidc_user) + conn_oidc = sign_in_user_via_oidc(conn, oidc_user_with_role) {:ok, view_oidc, _html} = live(conn_oidc, "/") view_oidc |> element("a", "Profil") |> render_click() - assert_redirected(view_oidc, "/users/#{oidc_user.id}") + assert_redirected(view_oidc, "/users/#{oidc_user_with_role.id}") end end describe "authenticated views" do + # User must have a role with page permission to access /members, /users, etc. setup %{conn: conn} do - user = create_test_user(%{email: "test@example.com"}) + user = Mv.Fixtures.user_with_role_fixture("admin") conn = conn_with_password_user(conn, user) {:ok, conn: conn, user: user} end diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index 8cac22a..0edd2a4 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -441,18 +441,11 @@ defmodule MvWeb.RoleLiveTest do end test "only admin can access /admin/roles", %{conn: conn, actor: actor} do - {conn, _user} = create_non_admin_user(conn, actor) + {conn, user} = create_non_admin_user(conn, actor) - # Non-admin should be redirected or see error - # Note: Authorization is checked via can_access_page? which returns false - # The page might still mount but show no content or redirect - # For now, we just verify the page doesn't work as expected for non-admin - {:ok, _view, html} = live(conn, "/admin/roles") - - # Non-admin should not see "New Role" button (can? returns false) - # But the button might still be in HTML, just hidden or disabled - # We verify that the page loads but admin features are restricted - assert html =~ "Listing Roles" || html =~ "Roles" + # Non-admin (no role or non-admin role) is redirected by CheckPagePermission plug + assert {:error, {:redirect, %{to: to}}} = live(conn, "/admin/roles") + assert to == "/users/#{user.id}" end test "admin can access /admin/roles", %{conn: conn, actor: actor} do diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs new file mode 100644 index 0000000..71d625f --- /dev/null +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -0,0 +1,867 @@ +defmodule MvWeb.Plugs.CheckPagePermissionTest do + @moduledoc """ + Tests for the CheckPagePermission plug. + """ + use MvWeb.ConnCase, async: true + + alias MvWeb.Plugs.CheckPagePermission + alias Mv.Fixtures + + defp conn_with_user(path, user) do + build_conn(:get, path) + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:phoenix_router, MvWeb.Router) + |> Plug.Conn.assign(:current_user, user) + end + + defp conn_without_user(path) do + build_conn(:get, path) + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:phoenix_router, MvWeb.Router) + end + + describe "static routes" do + test "user with permission for \"/members\" can access (conn not halted)" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/members", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "user without permission for \"/members\" is denied (conn halted, redirected to user profile)" do + user = Fixtures.user_with_role_fixture("own_data") + conn = conn_with_user("/members", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "flash error message present after denial" do + user = Fixtures.user_with_role_fixture("own_data") + conn = conn_with_user("/members", user) |> CheckPagePermission.call([]) + + assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) == + "You don't have permission to access this page." + end + end + + describe "dynamic routes" do + test "user with \"/members/:id\" permission can access \"/members/123\"" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "user with \"/members/:id/edit\" permission can access \"/members/456/edit\"" do + user = Fixtures.user_with_role_fixture("normal_user") + conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "user with only \"/members/:id\" cannot access \"/members/123/edit\"" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "own_data user with linked member can access /members/:id/edit (plug direct call)" do + member = Fixtures.member_fixture() + user = Fixtures.user_with_role_fixture("own_data") + user_with_member = Mv.Authorization.Actor.ensure_loaded(user) + # Simulate user with linked member (struct may not have member_id after session load) + user_with_member = %{user_with_member | member_id: member.id} + + assert CheckPagePermission.user_can_access_page?( + user_with_member, + "/members/#{member.id}/edit" + ), + "plug must allow own_data user with linked member to access member edit" + + conn = + conn_with_user("/members/#{member.id}/edit", user_with_member) + |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "own_data user with linked member can access /members/:id/show/edit (plug direct call)" do + member = Fixtures.member_fixture() + user = Fixtures.user_with_role_fixture("own_data") + user_with_member = Mv.Authorization.Actor.ensure_loaded(user) + user_with_member = %{user_with_member | member_id: member.id} + + assert CheckPagePermission.user_can_access_page?( + user_with_member, + "/members/#{member.id}/show/edit" + ) + + conn = + conn_with_user("/members/#{member.id}/show/edit", user_with_member) + |> CheckPagePermission.call([]) + + refute conn.halted + end + end + + describe "read_only and normal_user denied on admin routes" do + test "read_only cannot access /admin/roles" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "normal_user cannot access /admin/roles" do + user = Fixtures.user_with_role_fixture("normal_user") + conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "read_only cannot access /members/new" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/members/new", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + describe "wildcard" do + test "admin with \"*\" permission can access any page" do + user = Fixtures.user_with_role_fixture("admin") + conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "admin can access \"/members/999/edit\"" do + user = Fixtures.user_with_role_fixture("admin") + conn = conn_with_user("/members/999/edit", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + end + + describe "unauthenticated user" do + test "nil current_user is denied and redirected to \"/sign-in\"" do + conn = conn_without_user("/members") |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/sign-in" + + assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) == + "You don't have permission to access this page." + end + end + + describe "public paths" do + test "unauthenticated user can access /auth/sign-in (no redirect)" do + conn = conn_without_user("/auth/sign-in") |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "unauthenticated user can access /register" do + conn = conn_without_user("/register") |> CheckPagePermission.call([]) + + refute conn.halted + end + end + + describe "error handling" do + test "user with no role is denied" do + user = Fixtures.user_with_role_fixture("admin") + user_without_role = %{user | role: nil} + conn = conn_with_user("/members", user_without_role) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "user with invalid permission_set_name is denied" do + user = Fixtures.user_with_role_fixture("admin") + bad_role = %{user.role | permission_set_name: "invalid_set"} + user_bad_role = %{user | role: bad_role} + conn = conn_with_user("/members", user_bad_role) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + # Integration: dispatch through full router (endpoint) so pipeline and load_from_session run. + # These tests ensure a Mitglied (own_data) user is denied on every forbidden path. + describe "integration: Mitglied (own_data) denied on all forbidden paths via full router" do + @tag role: :member + test "GET /members redirects to user profile with error flash", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/members") + assert redirected_to(conn) == "/users/#{user.id}" + + assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) =~ + "don't have permission" + end + + @tag role: :member + test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do + assert user.role.permission_set_name == "own_data", + "setup must provide Mitglied (own_data) user" + + conn = get(conn, "/members/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /users redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/users") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/users/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/settings") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /membership_fee_settings redirects to user profile", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/membership_fee_settings") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /membership_fee_types redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/membership_fee_types") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /membership_fee_types/new redirects to user profile", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/membership_fee_types/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /groups redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/groups") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/groups/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/admin/roles") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /admin/roles/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/admin/roles/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + # Dynamic routes need a valid path segment; use a real UUID from fixtures. + describe "integration: Mitglied denied on dynamic forbidden paths via full router" do + setup %{conn: conn, current_user: current_user} do + member = Mv.Fixtures.member_fixture() + role = Mv.Fixtures.role_fixture("admin") + {:ok, conn: conn, current_user: current_user, member_id: member.id, role_id: role.id} + end + + @tag role: :member + test "GET /members/:id/edit redirects to user profile", %{ + conn: conn, + member_id: id, + current_user: user + } do + conn = get(conn, "/members/#{id}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /members/:id/show/edit redirects to user profile", %{ + conn: conn, + member_id: id, + current_user: user + } do + conn = get(conn, "/members/#{id}/show/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /members/:id (unlinked member show) redirects to user profile", %{ + conn: conn, + member_id: id, + current_user: user + } do + conn = get(conn, "/members/#{id}") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /users/:id redirects to user profile", %{conn: conn, current_user: user} do + other_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = get(conn, "/users/#{other_user.id}") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /users/:id/edit redirects to user profile", %{conn: conn, current_user: user} do + other_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = get(conn, "/users/#{other_user.id}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /users/:id/show/edit redirects to user profile", %{conn: conn, current_user: user} do + other_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = get(conn, "/users/#{other_user.id}/show/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /membership_fee_types/:id/edit redirects to user profile", %{ + conn: conn, + current_user: user + } do + type = + Mv.MembershipFees.MembershipFeeType + |> Ash.Query.limit(1) + |> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor()) + |> List.first() + + if type do + conn = get(conn, "/membership_fee_types/#{type.id}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + @tag role: :member + test "GET /groups/:slug redirects to user profile", %{conn: conn, current_user: user} do + group = Mv.Membership.Group |> Ash.Query.limit(1) |> Ash.read!() |> List.first() + + if group, + do: assert(redirected_to(get(conn, "/groups/#{group.slug}")) == "/users/#{user.id}") + end + + @tag role: :member + test "GET /admin/roles/:id redirects to user profile", %{ + conn: conn, + role_id: id, + current_user: user + } do + conn = get(conn, "/admin/roles/#{id}") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /admin/roles/:id/edit redirects to user profile", %{ + conn: conn, + role_id: id, + current_user: user + } do + conn = get(conn, "/admin/roles/#{id}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + describe "integration: Mitglied (own_data) can access allowed paths via full router" do + @tag role: :member + test "GET / redirects to user profile (root not allowed for own_data)", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do + conn = get(conn, "/users/#{user.id}") + assert conn.status == 200 + end + + @tag role: :member + test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do + conn = get(conn, "/users/#{user.id}/edit") + assert conn.status == 200 + end + + @tag role: :member + test "GET /users/:id/show/edit (own profile show edit) returns 200", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/users/#{user.id}/show/edit") + assert conn.status == 200 + end + + # Full-router test: session may not preserve member_id; plug logic covered by unit test "own_data user with linked member can access /members/:id/edit (plug direct call)" + @tag role: :member + @tag :skip + test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{ + conn: conn, + current_user: user + } do + member = Mv.Fixtures.member_fixture() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, user_after_update} = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.force_set_argument(:member, %{id: member.id}) + |> Ash.update(actor: system_actor) + + user_with_member = + user_after_update + |> Ash.load!([:role], domain: Mv.Accounts) + |> Mv.Authorization.Actor.ensure_loaded() + |> Map.put(:member_id, member.id) + + conn = conn_with_password_user(conn, user_with_member) + + conn = get(conn, "/members/#{member.id}/edit") + assert conn.status == 200 + end + + @tag role: :member + @tag :skip + test "GET /members/:id/show/edit (linked member show edit) returns 200 when user has linked member", + %{ + conn: conn, + current_user: user + } do + member = Mv.Fixtures.member_fixture() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, user_after_update} = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.force_set_argument(:member, %{id: member.id}) + |> Ash.update(actor: system_actor) + + user_with_member = + user_after_update + |> Ash.load!([:role], domain: Mv.Accounts) + |> Mv.Authorization.Actor.ensure_loaded() + |> Map.put(:member_id, member.id) + + conn = conn_with_password_user(conn, user_with_member) + + conn = get(conn, "/members/#{member.id}/show/edit") + assert conn.status == 200 + end + + # Skipped: MemberLive.Show requires membership fee cycle data; plug allows access (page loads then LiveView may error). + @tag role: :member + @tag :skip + test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + member = Mv.Fixtures.member_fixture() + + user = + user + |> Ash.Changeset.for_update(:update_user, %{}) + |> Ash.Changeset.force_set_argument(:member, %{id: member.id}) + |> Ash.update(actor: system_actor) + |> case do + {:ok, u} -> Ash.load!(u, :role, domain: Mv.Accounts, actor: system_actor) + {:error, _} -> user + end + + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(user) + |> get("/members/#{member.id}") + + assert conn.status == 200 + end + end + + # read_only (Vorstand/Buchhaltung): allowed /, /members, /members/:id, /groups, /groups/:slug + describe "integration: read_only (Vorstand/Buchhaltung) allowed paths via full router" do + setup %{conn: conn, current_user: current_user} do + member = Mv.Fixtures.member_fixture() + group = Mv.Fixtures.group_fixture() + + {:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug} + end + + @tag role: :read_only + test "GET / returns 200", %{conn: conn} do + conn = get(conn, "/") + assert conn.status == 200 + end + + @tag role: :read_only + test "GET /members returns 200", %{conn: conn} do + conn = get(conn, "/members") + assert conn.status == 200 + end + + @tag role: :read_only + test "GET /members/:id returns 200", %{conn: conn, member_id: id} do + conn = get(conn, "/members/#{id}") + assert conn.status == 200 + end + + @tag role: :read_only + test "GET /groups returns 200", %{conn: conn} do + conn = get(conn, "/groups") + assert conn.status == 200 + end + + @tag role: :read_only + test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do + conn = get(conn, "/groups/#{slug}") + assert conn.status == 200 + end + end + + describe "integration: read_only denied paths via full router" do + setup %{conn: conn, current_user: current_user} do + member = Mv.Fixtures.member_fixture() + role = Mv.Fixtures.role_fixture("admin") + group = Mv.Fixtures.group_fixture() + + type = + Mv.MembershipFees.MembershipFeeType + |> Ash.Query.limit(1) + |> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor()) + |> List.first() + + {:ok, + conn: conn, + current_user: current_user, + member_id: member.id, + role_id: role.id, + group_slug: group.slug, + fee_type_id: type && type.id} + end + + @tag role: :read_only + test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/members/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /members/:id/edit redirects to user profile", %{ + conn: conn, + member_id: id, + current_user: user + } do + conn = get(conn, "/members/#{id}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /users redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/users") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/users/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/settings") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /membership_fee_settings redirects to user profile", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/membership_fee_settings") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /membership_fee_types redirects to user profile", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/membership_fee_types") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/groups/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /groups/:slug/edit redirects to user profile", %{ + conn: conn, + current_user: user, + group_slug: slug + } do + conn = get(conn, "/groups/#{slug}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/admin/roles") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /admin/roles/:id redirects to user profile", %{ + conn: conn, + role_id: id, + current_user: user + } do + conn = get(conn, "/admin/roles/#{id}") + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug + describe "integration: normal_user (Kassenwart) allowed paths via full router" do + setup %{conn: conn, current_user: current_user} do + member = Mv.Fixtures.member_fixture() + group = Mv.Fixtures.group_fixture() + + {:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug} + end + + @tag role: :normal_user + test "GET / returns 200", %{conn: conn} do + conn = get(conn, "/") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /members returns 200", %{conn: conn} do + conn = get(conn, "/members") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /members/new returns 200", %{conn: conn} do + conn = get(conn, "/members/new") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /members/:id returns 200", %{conn: conn, member_id: id} do + conn = get(conn, "/members/#{id}") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /members/:id/edit returns 200", %{conn: conn, member_id: id} do + conn = get(conn, "/members/#{id}/edit") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /groups returns 200", %{conn: conn} do + conn = get(conn, "/groups") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do + conn = get(conn, "/groups/#{slug}") + assert conn.status == 200 + end + end + + describe "integration: normal_user denied paths via full router" do + setup %{conn: conn, current_user: current_user} do + other_user = Mv.Fixtures.user_with_role_fixture("admin") + role = Mv.Fixtures.role_fixture("admin") + group = Mv.Fixtures.group_fixture() + + {:ok, + conn: conn, + current_user: current_user, + other_user_id: other_user.id, + role_id: role.id, + group_slug: group.slug} + end + + @tag role: :normal_user + test "GET /users redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/users") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/users/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /users/:id redirects to user profile", %{ + conn: conn, + current_user: user, + other_user_id: id + } do + conn = get(conn, "/users/#{id}") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/settings") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /membership_fee_settings redirects to user profile", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/membership_fee_settings") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /membership_fee_types redirects to user profile", %{ + conn: conn, + current_user: user + } do + conn = get(conn, "/membership_fee_types") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/groups/new") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /groups/:slug/edit redirects to user profile", %{ + conn: conn, + current_user: user, + group_slug: slug + } do + conn = get(conn, "/groups/#{slug}/edit") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/admin/roles") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :normal_user + test "GET /admin/roles/:id redirects to user profile", %{ + conn: conn, + role_id: id, + current_user: user + } do + conn = get(conn, "/admin/roles/#{id}") + assert redirected_to(conn) == "/users/#{user.id}" + end + end + + describe "integration: admin can access all protected routes via full router" do + setup %{conn: conn, current_user: current_user} do + member = Mv.Fixtures.member_fixture() + role = Mv.Fixtures.role_fixture("admin") + group = Mv.Fixtures.group_fixture() + + {:ok, + conn: conn, + current_user: current_user, + member_id: member.id, + role_id: role.id, + group_slug: group.slug} + end + + @tag role: :admin + test "GET / returns 200", %{conn: conn} do + conn = get(conn, "/") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /members returns 200", %{conn: conn} do + conn = get(conn, "/members") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /users returns 200", %{conn: conn} do + conn = get(conn, "/users") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /settings returns 200", %{conn: conn} do + conn = get(conn, "/settings") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /membership_fee_settings returns 200", %{conn: conn} do + conn = get(conn, "/membership_fee_settings") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /admin/roles returns 200", %{conn: conn} do + conn = get(conn, "/admin/roles") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /members/:id returns 200", %{conn: conn, member_id: id} do + conn = get(conn, "/members/#{id}") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /admin/roles/:id returns 200", %{conn: conn, role_id: id} do + conn = get(conn, "/admin/roles/#{id}") + assert conn.status == 200 + end + + @tag role: :admin + test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do + conn = get(conn, "/groups/#{slug}") + assert conn.status == 200 + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 7dd118b..745be5a 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -175,6 +175,18 @@ defmodule MvWeb.ConnCase do authenticated_conn = conn_with_password_user(conn, member_user) {authenticated_conn, member_user} + :read_only -> + # Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings + read_only_user = Mv.Fixtures.user_with_role_fixture("read_only") + authenticated_conn = conn_with_password_user(conn, read_only_user) + {authenticated_conn, read_only_user} + + :normal_user -> + # Kassenwart: can read/update members, groups; cannot access users/settings/admin + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + authenticated_conn = conn_with_password_user(conn, normal_user) + {authenticated_conn, normal_user} + :unauthenticated -> # No authentication for unauthenticated tests {conn, nil}