feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
CI / ruff (push) Successful in 9s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running

- Add admin store roles page with merchant→store cascading for superadmin
  and store-only selection for platform admin
- Add permission catalog API with translated labels/descriptions (en/fr/de/lb)
- Add permission translations to all 15 module locale files (60 files total)
- Add info icon tooltips for permission descriptions in role editor
- Add store roles menu item and admin menu item in module definition
- Fix store-selector.js URL construction bug when apiEndpoint has query params
- Add admin store roles API (CRUD + platform scoping)
- Add integration tests for admin store roles and permission catalog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 23:31:27 +01:00
parent 2b55e7458b
commit f95db7c0b1
83 changed files with 3491 additions and 513 deletions

View File

@@ -0,0 +1,276 @@
# app/modules/tenancy/tests/integration/test_admin_store_roles_api.py
"""
Integration tests for admin store role management API endpoints.
Tests the admin role management endpoints at:
/api/v1/admin/store-roles
Authentication: Uses super_admin_headers and platform_admin_headers
fixtures from tests/fixtures/auth_fixtures.py.
"""
import uuid
import pytest
from app.modules.tenancy.models import (
Merchant,
Platform,
Role,
Store,
StorePlatform,
User,
)
from middleware.auth import AuthManager
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/admin/store-roles"
@pytest.fixture
def admin_store_merchant(db):
"""Create a merchant for admin role tests."""
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"admin_role_owner_{uid}@test.com",
username=f"admin_role_owner_{uid}",
hashed_password=auth.hash_password("ownerpass123"),
role="merchant_owner",
is_active=True,
)
db.add(owner)
db.flush()
merchant = Merchant(
name="Admin Role Test Merchant",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def admin_role_store(db, admin_store_merchant):
"""Create a store for admin role tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=admin_store_merchant.id,
store_code=f"ADMROLE_{uid.upper()}",
subdomain=f"admrole{uid}",
name=f"Admin Role Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def admin_role_custom(db, admin_role_store):
"""Create a custom role for update/delete tests."""
role = Role(
store_id=admin_role_store.id,
name="admin_test_custom_role",
permissions=["products.view", "orders.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def test_platform(db):
"""Create a platform for scoping tests."""
uid = uuid.uuid4().hex[:8]
platform = Platform(
code=f"test_{uid}",
name=f"Test Platform {uid}",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def store_on_platform(db, admin_role_store, test_platform):
"""Link the test store to a platform."""
sp = StorePlatform(
store_id=admin_role_store.id,
platform_id=test_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
# ============================================================================
# Super Admin Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminListStoreRoles:
"""Tests for GET /api/v1/admin/store-roles."""
def test_list_roles_as_super_admin(
self, client, super_admin_headers, admin_role_store
):
"""Super admin can list roles for any store."""
response = client.get(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "roles" in data
assert "total" in data
# Default preset roles should be created
assert data["total"] >= 5
def test_list_roles_requires_store_id(self, client, super_admin_headers):
"""GET /store-roles without store_id returns 422."""
response = client.get(BASE, headers=super_admin_headers)
assert response.status_code == 422
def test_list_roles_unauthenticated(self, client, admin_role_store):
"""Unauthenticated request is rejected."""
response = client.get(f"{BASE}?store_id={admin_role_store.id}")
assert response.status_code in (401, 403)
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminCreateStoreRole:
"""Tests for POST /api/v1/admin/store-roles."""
def test_create_role_as_super_admin(
self, client, super_admin_headers, admin_role_store
):
"""Super admin can create a custom role for any store."""
response = client.post(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "admin_created_role",
"permissions": ["products.view", "orders.view"],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "admin_created_role"
assert "products.view" in data["permissions"]
def test_create_preset_name_rejected(
self, client, super_admin_headers, admin_role_store
):
"""Cannot create a role with a preset name."""
response = client.post(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "manager",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminUpdateStoreRole:
"""Tests for PUT /api/v1/admin/store-roles/{role_id}."""
def test_update_role_as_super_admin(
self, client, super_admin_headers, admin_role_store, admin_role_custom
):
"""Super admin can update a custom role."""
response = client.put(
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "renamed_admin_role",
"permissions": ["products.view", "products.edit"],
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "renamed_admin_role"
assert "products.edit" in data["permissions"]
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminDeleteStoreRole:
"""Tests for DELETE /api/v1/admin/store-roles/{role_id}."""
def test_delete_role_as_super_admin(
self, client, super_admin_headers, admin_role_store, admin_role_custom
):
"""Super admin can delete a custom role."""
response = client.delete(
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 204
def test_delete_nonexistent_role(
self, client, super_admin_headers, admin_role_store
):
"""Deleting nonexistent role returns 422."""
response = client.delete(
f"{BASE}/99999?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 422
# ============================================================================
# Permission Catalog Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminPermissionCatalog:
"""Tests for GET /api/v1/admin/store-roles/permissions/catalog."""
def test_catalog_returns_categories(self, client, super_admin_headers):
"""GET /permissions/catalog returns categories with permissions."""
response = client.get(
f"{BASE}/permissions/catalog",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert len(data["categories"]) > 0
def test_catalog_permission_has_metadata(self, client, super_admin_headers):
"""Each permission has id, label, description, and is_owner_only."""
response = client.get(
f"{BASE}/permissions/catalog",
headers=super_admin_headers,
)
data = response.json()
perm = data["categories"][0]["permissions"][0]
assert "id" in perm
assert "label" in perm
assert "description" in perm
assert "is_owner_only" in perm

View File

@@ -353,3 +353,54 @@ class TestDeleteRole:
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()
# ============================================================================
# GET /team/permissions/catalog
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestPermissionCatalog:
"""Tests for GET /api/v1/store/team/permissions/catalog."""
def test_catalog_returns_categories(self, client, role_auth):
"""GET /permissions/catalog returns categories with permissions."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert len(data["categories"]) > 0
def test_catalog_category_has_permissions(self, client, role_auth):
"""Each category contains permission items."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
for category in data["categories"]:
assert "id" in category
assert "label" in category
assert "permissions" in category
assert len(category["permissions"]) > 0
def test_catalog_permission_has_metadata(self, client, role_auth):
"""Each permission has id, label, description, and is_owner_only."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
perm = data["categories"][0]["permissions"][0]
assert "id" in perm
assert "label" in perm
assert "description" in perm
assert "is_owner_only" in perm
def test_catalog_includes_team_permissions(self, client, role_auth):
"""Catalog includes team permissions from tenancy module."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
all_perm_ids = {
p["id"]
for cat in data["categories"]
for p in cat["permissions"]
}
assert "team.view" in all_perm_ids
assert "team.edit" in all_perm_ids

View File

@@ -12,7 +12,14 @@ from app.modules.tenancy.exceptions import (
InvalidRoleException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, User
from app.modules.tenancy.models import (
Platform,
Role,
Store,
StorePlatform,
StoreUser,
User,
)
from app.modules.tenancy.services.store_team_service import store_team_service
# =============================================================================
@@ -728,3 +735,80 @@ class TestStoreTeamServiceDeleteRole:
store_id=team_store.id,
role_id=role.id,
)
# =============================================================================
# ADMIN STORE ACCESS VALIDATION
# =============================================================================
@pytest.mark.unit
@pytest.mark.tenancy
class TestValidateAdminStoreAccess:
"""Tests for validate_admin_store_access()."""
def test_super_admin_can_access_any_store(self, db, team_store):
"""Super admin (accessible_platform_ids=None) can access any store."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = None
store = store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
assert store.id == team_store.id
def test_platform_admin_can_access_store_in_their_platform(self, db, team_store):
"""Platform admin can access stores in their assigned platform."""
from unittest.mock import MagicMock
# Create a platform and link the store
platform = Platform(
code=f"test_plat_{uuid.uuid4().hex[:6]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.flush()
sp = StorePlatform(
store_id=team_store.id,
platform_id=platform.id,
is_active=True,
)
db.add(sp)
db.flush()
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = [platform.id]
store = store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
assert store.id == team_store.id
def test_platform_admin_cannot_access_store_outside_platform(self, db, team_store):
"""Platform admin cannot access stores outside their platform."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
# Platform ID 99999 does not have the test store
user_ctx.get_accessible_platform_ids.return_value = [99999]
with pytest.raises(InvalidRoleException, match="do not have access"):
store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
def test_nonexistent_store_raises_error(self, db):
"""Accessing a nonexistent store raises InvalidRoleException."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = None
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.validate_admin_store_access(
db, user_ctx, 99999
)