feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
Some checks failed
- 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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user