Compare commits

...

6 commits

Author SHA1 Message Date
ae1605c447 Add sidebar authorization tests
Some checks reported errors
continuous-integration/drone/push Build was killed
Assert menu visibility per role: admin, read_only, normal_user,
own_data, nil user, user without role.
2026-02-03 16:35:36 +01:00
bda1edebcb Gate sidebar menu items by can_access_page?
Members, Fee Types and Administration subitems only shown when user
has page permission. Add admin_menu_visible? helper. Sidebar test
uses admin user so menu items render.
2026-02-03 16:35:35 +01:00
3d84b1f030 Add User LiveView authorization tests
Covers admin, read_only, member, normal_user for Index and Show.
Asserts New User / Edit / Delete visibility and redirect for non-admin.
2026-02-03 16:35:33 +01:00
f85d61d20c Apply UI authorization to User LiveViews (Index and Show)
Gate New User button, Edit and Delete links with can?/3.
Edit button on User Show visible only when user can update the user.
2026-02-03 16:35:32 +01:00
d41252ce56 Add Member LiveView authorization tests
Covers read_only, normal_user, admin, own_data for Index and Show.
Asserts New Member / Edit / Delete visibility and redirect for Mitglied.
2026-02-03 16:35:30 +01:00
f30ef4c145 Apply UI authorization to Member LiveViews (Index and Show)
Gate New Member button, Edit and Delete links with can?/3.
Edit button on Member Show visible only when user can update the member.
2026-02-03 16:35:29 +01:00
9 changed files with 403 additions and 49 deletions

View file

@ -70,33 +70,56 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_menu(assigns) do
~H"""
<ul class="menu flex-1 w-full p-2" role="menubar">
<%= if can_access_page?(@current_user, "/members") do %>
<.menu_item
href={~p"/members"}
icon="hero-users"
label={gettext("Members")}
/>
<% end %>
<%= if can_access_page?(@current_user, "/membership_fee_types") do %>
<.menu_item
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<% end %>
<!-- Nested Admin Menu -->
<%= if admin_menu_visible?(@current_user) do %>
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
<%= if can_access_page?(@current_user, "/users") do %>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<% end %>
<%= if can_access_page?(@current_user, "/groups") do %>
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<% end %>
<%= if can_access_page?(@current_user, "/admin/roles") do %>
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<% end %>
<%= if can_access_page?(@current_user, "/membership_fee_settings") do %>
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Fee Settings")}
/>
<% end %>
<%= if can_access_page?(@current_user, "/settings") do %>
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
<% end %>
</.menu_group>
<% end %>
</ul>
"""
end
defp admin_menu_visible?(user) do
Enum.any?(admin_page_paths(), &can_access_page?(user, &1))
end
defp admin_page_paths do
["/users", "/groups", "/admin/roles", "/membership_fee_settings", "/settings"]
end
attr :href, :string, required: true, doc: "Navigation path"
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"

View file

@ -23,9 +23,11 @@
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<% end %>
</:actions>
</.header>
@ -297,16 +299,20 @@
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
<% end %>
</:action>
<:action :let={member}>
<%= if can?(@current_user, :destroy, member) do %>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -39,9 +39,11 @@ defmodule MvWeb.MemberLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<%= if can?(@current_user, :update, @member) do %>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
<% end %>
</div>
<%!-- Tab Navigation --%>

View file

@ -2,9 +2,11 @@
<.header>
{gettext("Listing Users")}
<:actions>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.button variant="primary" navigate={~p"/users/new"}>
<.icon name="hero-plus" /> {gettext("New User")}
</.button>
<% end %>
</:actions>
</.header>
@ -62,16 +64,20 @@
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link>
<% end %>
</:action>
<:action :let={user}>
<%= if can?(@current_user, :destroy, user) do %>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -41,9 +41,11 @@ defmodule MvWeb.UserLive.Show do
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<%= if can?(@current_user, :update, @user) do %>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
</.button>
<% end %>
</:actions>
</.header>

View file

@ -22,9 +22,14 @@ defmodule MvWeb.Layouts.SidebarTest do
# =============================================================================
# Returns assigns for an authenticated user with all required attributes.
# User has admin role so can_access_page? returns true for all sidebar links.
defp authenticated_assigns(mobile \\ false) do
%{
current_user: %{id: "user-123", email: "test@example.com"},
current_user: %{
id: "user-123",
email: "test@example.com",
role: %{permission_set_name: "admin"}
},
club_name: "Test Club",
mobile: mobile
}

View file

@ -0,0 +1,120 @@
defmodule MvWeb.SidebarAuthorizationTest do
@moduledoc """
Tests for sidebar menu visibility based on user permissions (can_access_page?).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import MvWeb.Layouts.Sidebar
alias Mv.Fixtures
defp render_sidebar(assigns) do
render_component(&sidebar/1, assigns)
end
defp sidebar_assigns(current_user, opts \\ []) do
mobile = Keyword.get(opts, :mobile, false)
club_name = Keyword.get(opts, :club_name, "Test Club")
%{
current_user: current_user,
club_name: club_name,
mobile: mobile
}
end
describe "sidebar menu with admin user" do
test "shows Members, Fee Types and Administration with all subitems" do
user = Fixtures.user_with_role_fixture("admin")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/membership_fee_types")
assert html =~ ~s(aria-label="Administration")
assert html =~ ~s(href="/users")
assert html =~ ~s(href="/groups")
assert html =~ ~s(href="/admin/roles")
assert html =~ ~s(href="/membership_fee_settings")
assert html =~ ~s(href="/settings")
end
end
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
test "shows Members and Groups (from Administration)" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/groups")
end
test "does not show Fee Types, Users, Roles or Settings" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/admin/roles")
refute html =~ ~s(href="/settings")
end
end
describe "sidebar menu with normal_user (Kassenwart)" do
test "shows Members and Groups" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/groups")
end
test "does not show Fee Types, Users, Roles or Settings" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/admin/roles")
refute html =~ ~s(href="/settings")
end
end
describe "sidebar menu with own_data user (Mitglied)" do
test "does not show Members link (no /members page access)" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/members")
end
test "does not show Fee Types or Administration" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(aria-label="Administration")
end
end
describe "sidebar with nil current_user" do
test "does not render menu items (only header and footer when present)" do
html = render_sidebar(sidebar_assigns(nil))
refute html =~ ~s(role="menubar")
refute html =~ ~s(href="/members")
end
end
describe "sidebar with user without role" do
test "does not show any navigation links" do
user = %{id: "user-no-role", email: "noreply@test.com", role: nil}
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/members")
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
end
end
end

View file

@ -0,0 +1,106 @@
defmodule MvWeb.MemberLiveAuthorizationTest do
@moduledoc """
Tests for UI authorization on Member LiveViews (Index and Show).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Fixtures
# Use literal strings for button/link text (matches default Gettext locale)
@new_member_text "New Member"
@edit_member_text "Edit Member"
describe "Member Index - Vorstand (read_only)" do
@tag role: :read_only
test "sees member list but not New Member button", %{conn: conn} do
_member = Fixtures.member_fixture()
{:ok, _view, html} = live(conn, "/members")
refute html =~ @new_member_text
end
@tag role: :read_only
test "does not see Edit or Delete buttons in table", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "a[href=\"/members/#{member.id}/edit\"]")
refute has_element?(view, "a[phx-click*='delete']")
end
end
describe "Member Index - Kassenwart (normal_user)" do
@tag role: :normal_user
test "sees New Member and Edit buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, html} = live(conn, "/members")
assert html =~ @new_member_text
assert has_element?(view, "a[href=\"/members/#{member.id}/edit\"]")
end
@tag role: :normal_user
test "does not see Delete button", %{conn: conn} do
_member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "a[phx-click*='delete']")
end
end
describe "Member Index - Admin" do
@tag role: :admin
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, html} = live(conn, "/members")
assert html =~ @new_member_text
assert has_element?(view, "a[href=\"/members/#{member.id}/edit\"]")
assert has_element?(view, "a[phx-click*='delete']")
end
end
describe "Member Index - Mitglied (own_data)" do
@tag role: :member
test "is redirected when accessing /members", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
assert to == "/users/#{user.id}"
end
end
describe "Member Show - Edit button visibility" do
@tag role: :admin
test "admin sees Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, _view, html} = live(conn, "/members/#{member.id}")
assert html =~ @edit_member_text
end
@tag role: :read_only
test "read_only does not see Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, _view, html} = live(conn, "/members/#{member.id}")
refute html =~ @edit_member_text
end
@tag role: :normal_user
test "normal_user sees Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, _view, html} = live(conn, "/members/#{member.id}")
assert html =~ @edit_member_text
end
end
end

View file

@ -0,0 +1,84 @@
defmodule MvWeb.UserLiveAuthorizationTest do
@moduledoc """
Tests for UI authorization on User LiveViews (Index and Show).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Fixtures
@new_user_text "New User"
@edit_user_text "Edit User"
describe "User Index - Admin" do
@tag role: :admin
test "sees New User, Edit and Delete buttons", %{conn: conn} do
user = Fixtures.user_with_role_fixture("admin")
{:ok, view, html} = live(conn, "/users")
assert html =~ @new_user_text
assert has_element?(view, "a[href=\"/users/#{user.id}/edit\"]")
assert has_element?(view, "a[phx-click*='delete']")
end
end
describe "User Index - Non-Admin is redirected" do
@tag role: :read_only
test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
@tag role: :member
test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
@tag role: :normal_user
test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
end
describe "User Show - own profile" do
@tag role: :member
test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
{:ok, _view, html} = live(conn, "/users/#{user.id}")
assert html =~ @edit_user_text
end
@tag role: :read_only
test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
{:ok, _view, html} = live(conn, "/users/#{user.id}")
assert html =~ @edit_user_text
end
@tag role: :admin
test "admin sees Edit button on user show", %{conn: conn} do
user = Fixtures.user_with_role_fixture("read_only")
{:ok, _view, html} = live(conn, "/users/#{user.id}")
assert html =~ @edit_user_text
end
end
describe "User Show - other user (non-admin redirected)" do
@tag role: :member
test "member is redirected when accessing other user's profile", %{
conn: conn,
current_user: current_user
} do
other_user = Fixtures.user_with_role_fixture("admin")
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
assert to == "/users/#{current_user.id}"
end
end
end