feat: implement complete RBAC access control with tests
Some checks failed
CI / pytest (push) Failing after 45m29s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 9s

Add 4-layer access control stack (subscription → module → menu → permissions):
- P1: Wire requires_permission into menu sidebar filtering
- P2: Expose window.USER_PERMISSIONS for Alpine.js client-side gating
- P3: Add page-level permission guards on store routes
- P4: Role CRUD API endpoints and role editor UI
- P5: Audit trail for all role/permission changes

Includes unit tests (menu permission filtering, role CRUD service) and
integration tests (role API endpoints). All 404 core+tenancy tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:26:59 +01:00
parent 962862ccc1
commit cb3bc3c118
29 changed files with 1850 additions and 17 deletions

View File

@@ -28,7 +28,10 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
RoleCreate,
RoleListResponse,
RoleResponse,
RoleUpdate,
TeamMemberInvite,
TeamMemberListResponse,
TeamMemberResponse,
@@ -392,6 +395,86 @@ def list_roles(
return RoleListResponse(roles=roles, total=len(roles))
@store_team_router.post("/roles", response_model=RoleResponse, status_code=201)
def create_role(
request: Request,
role_data: RoleCreate,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_store_owner),
):
"""
Create a custom role for the store.
**Required:** Store owner only.
Preset role names (manager, staff, support, viewer, marketing) cannot be used.
"""
store = request.state.store
role = store_team_service.create_custom_role(
db=db,
store_id=store.id,
name=role_data.name,
permissions=role_data.permissions,
actor_user_id=current_user.id,
)
db.commit()
return role
@store_team_router.put("/roles/{role_id}", response_model=RoleResponse)
def update_role(
request: Request,
role_id: int,
role_data: RoleUpdate,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_store_owner),
):
"""
Update a role's name and/or permissions.
**Required:** Store owner only.
"""
store = request.state.store
role = store_team_service.update_role(
db=db,
store_id=store.id,
role_id=role_id,
name=role_data.name,
permissions=role_data.permissions,
actor_user_id=current_user.id,
)
db.commit()
return role
@store_team_router.delete("/roles/{role_id}", status_code=204)
def delete_role(
request: Request,
role_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_store_owner),
):
"""
Delete a custom role.
**Required:** Store owner only.
Preset roles cannot be deleted.
Roles with assigned team members cannot be deleted.
"""
store = request.state.store
store_team_service.delete_role(
db=db,
store_id=store.id,
role_id=role_id,
actor_user_id=current_user.id,
)
db.commit()
# ============================================================================
# Permission Routes
# ============================================================================

View File

@@ -19,6 +19,7 @@ from app.api.deps import (
get_current_store_optional,
get_db,
get_resolved_store_code,
require_store_page_permission,
)
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
@@ -90,7 +91,7 @@ async def store_login_page(
async def store_team_page(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
current_user: User = Depends(require_store_page_permission("team.view")),
db: Session = Depends(get_db),
):
"""
@@ -103,6 +104,26 @@ async def store_team_page(
)
@router.get(
"/team/roles", response_class=HTMLResponse, include_in_schema=False
)
async def store_roles_page(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(require_store_page_permission("team.view")),
db: Session = Depends(get_db),
):
"""
Render role editor page.
Store owners can create, edit, and delete custom roles
with a permission matrix UI.
"""
return templates.TemplateResponse(
"tenancy/store/roles.html",
get_store_context(request, db, current_user, store_code),
)
@router.get(
"/profile", response_class=HTMLResponse, include_in_schema=False
)

View File

@@ -26,9 +26,11 @@ def get_preset_permissions(preset_name: str) -> set[str]:
"""Get permissions for a preset role."""
return permission_discovery_service.get_preset_permissions(preset_name)
from app.modules.billing.exceptions import TierLimitExceededException
from app.modules.core.services.audit_aggregator import audit_aggregator
from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
InvalidRoleException,
TeamInvitationAlreadyAcceptedException,
TeamMemberAlreadyExistsException,
UserNotFoundException,
@@ -174,6 +176,20 @@ class StoreTeamService:
# TODO: Send invitation email
# self._send_invitation_email(email, store, invitation_token)
audit_aggregator.log(
db=db,
admin_user_id=inviter.id,
action="member.invite",
target_type="store_user",
target_id=str(store_user.id),
details={
"email": email,
"role": role_name,
"store_id": store.id,
"store_code": store.store_code,
},
)
return {
"invitation_token": invitation_token,
"email": email,
@@ -274,6 +290,7 @@ class StoreTeamService:
db: Session,
store: Store,
user_id: int,
actor_user_id: int | None = None,
) -> bool:
"""
Remove a team member from a store.
@@ -309,6 +326,21 @@ class StoreTeamService:
store_user.is_active = False
logger.info(f"Removed user {user_id} from store {store.store_code}")
if actor_user_id is not None:
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="member.remove",
target_type="store_user",
target_id=str(store_user.id),
details={
"user_id": user_id,
"store_id": store.id,
"store_code": store.store_code,
},
)
return True
except (UserNotFoundException, CannotRemoveOwnerException):
@@ -324,6 +356,7 @@ class StoreTeamService:
user_id: int,
new_role_name: str,
custom_permissions: list[str] | None = None,
actor_user_id: int | None = None,
) -> StoreUser:
"""
Update a team member's role.
@@ -363,14 +396,31 @@ class StoreTeamService:
custom_permissions=custom_permissions,
)
old_role_name = store_user.role.name if store_user.role else "none"
store_user.role_id = new_role.id
db.flush()
db.refresh(store_user)
logger.info(
f"Updated role for user {user_id} in store {store.store_code} "
f"to {new_role_name}"
)
if actor_user_id is not None:
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="member.role_change",
target_type="store_user",
target_id=str(store_user.id),
details={
"user_id": user_id,
"store_id": store.id,
"old_role": old_role_name,
"new_role": new_role_name,
},
)
return store_user
except (UserNotFoundException, CannotRemoveOwnerException):
@@ -470,6 +520,210 @@ class StoreTeamService:
for role in roles
]
# ========================================================================
# Role CRUD
# ========================================================================
PRESET_ROLE_NAMES = {"manager", "staff", "support", "viewer", "marketing"}
def create_custom_role(
self,
db: Session,
store_id: int,
name: str,
permissions: list[str],
actor_user_id: int | None = None,
) -> Role:
"""
Create a custom role for a store.
Args:
db: Database session
store_id: Store ID
name: Role name
permissions: List of permission IDs
actor_user_id: ID of user performing the action (for audit)
Returns:
Created Role object
Raises:
InvalidRoleException: If role name conflicts with a preset
"""
if name.lower() in self.PRESET_ROLE_NAMES:
raise InvalidRoleException(f"Cannot create role with preset name: {name}")
# Check for duplicate name
existing = (
db.query(Role)
.filter(Role.store_id == store_id, Role.name == name)
.first()
)
if existing:
raise InvalidRoleException(f"Role '{name}' already exists for this store")
# Validate permissions exist
valid_ids = permission_discovery_service.get_all_permission_ids()
invalid = set(permissions) - valid_ids
if invalid:
raise InvalidRoleException(f"Invalid permission IDs: {', '.join(sorted(invalid))}")
role = Role(
store_id=store_id,
name=name,
permissions=permissions,
)
db.add(role)
db.flush()
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="role.create",
target_type="role",
target_id=str(role.id),
details={"name": name, "permissions_count": len(permissions), "store_id": store_id},
)
return role
def update_role(
self,
db: Session,
store_id: int,
role_id: int,
name: str | None = None,
permissions: list[str] | None = None,
actor_user_id: int | None = None,
) -> Role:
"""
Update a role's name and/or permissions.
Args:
db: Database session
store_id: Store ID (for ownership check)
role_id: Role ID to update
name: New role name (optional)
permissions: New permission list (optional)
Returns:
Updated Role object
Raises:
InvalidRoleException: If role not found or name conflicts
"""
role = (
db.query(Role)
.filter(Role.id == role_id, Role.store_id == store_id)
.first()
)
if not role:
raise InvalidRoleException(f"Role {role_id} not found for this store")
if name is not None:
if name.lower() in self.PRESET_ROLE_NAMES and role.name.lower() != name.lower():
raise InvalidRoleException(f"Cannot rename to preset name: {name}")
# Check duplicate
duplicate = (
db.query(Role)
.filter(
Role.store_id == store_id,
Role.name == name,
Role.id != role_id,
)
.first()
)
if duplicate:
raise InvalidRoleException(f"Role '{name}' already exists for this store")
role.name = name
if permissions is not None:
valid_ids = permission_discovery_service.get_all_permission_ids()
invalid = set(permissions) - valid_ids
if invalid:
raise InvalidRoleException(
f"Invalid permission IDs: {', '.join(sorted(invalid))}"
)
old_permissions = role.permissions or []
role.permissions = permissions
db.flush()
details = {"role_name": role.name, "store_id": store_id}
if name is not None:
details["new_name"] = name
if permissions is not None:
added = set(permissions) - set(old_permissions)
removed = set(old_permissions) - set(permissions)
if added:
details["permissions_added"] = sorted(added)
if removed:
details["permissions_removed"] = sorted(removed)
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="role.update",
target_type="role",
target_id=str(role.id),
details=details,
)
return role
def delete_role(
self,
db: Session,
store_id: int,
role_id: int,
actor_user_id: int | None = None,
) -> None:
"""
Delete a custom role. Preset roles cannot be deleted.
Args:
db: Database session
store_id: Store ID (for ownership check)
role_id: Role ID to delete
Raises:
InvalidRoleException: If role not found or is a preset role
"""
role = (
db.query(Role)
.filter(Role.id == role_id, Role.store_id == store_id)
.first()
)
if not role:
raise InvalidRoleException(f"Role {role_id} not found for this store")
if role.name.lower() in self.PRESET_ROLE_NAMES:
raise InvalidRoleException(f"Cannot delete preset role: {role.name}")
# Check if any team members use this role
members_with_role = (
db.query(StoreUser)
.filter(StoreUser.store_id == store_id, StoreUser.role_id == role_id)
.count()
)
if members_with_role > 0:
raise InvalidRoleException(
f"Cannot delete role: {members_with_role} team member(s) still assigned"
)
role_name = role.name
db.delete(role)
db.flush()
audit_aggregator.log(
db=db,
admin_user_id=actor_user_id,
action="role.delete",
target_type="role",
target_id=str(role_id),
details={"role_name": role_name, "store_id": store_id},
)
# Private helper methods
def _generate_invitation_token(self) -> str:

View File

@@ -0,0 +1,338 @@
{# app/templates/store/roles.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Role Management{% endblock %}
{% block alpine_data %}storeRoles(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Role Management', subtitle='Create and manage custom roles with granular permissions') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loading', onclick='loadRoles()', variant='secondary') }}
<button
@click="openCreateModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Role
</button>
</div>
{% endcall %}
{{ loading_state('Loading roles...') }}
{{ error_state('Error loading roles') }}
<!-- Roles List -->
<div x-show="!loading && !error" class="space-y-6">
<template x-for="role in roles" :key="role.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="role.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="(role.permissions || []).length"></span> permissions
<template x-if="isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full dark:bg-blue-900 dark:text-blue-200">Preset</span>
</template>
<template x-if="!isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full dark:bg-green-900 dark:text-green-200">Custom</span>
</template>
</p>
</div>
<div class="flex items-center gap-2">
<button
@click="openEditModal(role)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-400 dark:bg-purple-900/20 dark:hover:bg-purple-900/40"
>
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
Edit
</button>
<button
x-show="!isPresetRole(role.name)"
@click="confirmDelete(role)"
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40"
>
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete
</button>
</div>
</div>
<!-- Permission tags -->
<div class="flex flex-wrap gap-1.5">
<template x-for="perm in (role.permissions || [])" :key="perm">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300" x-text="perm"></span>
</template>
<template x-if="!role.permissions || role.permissions.length === 0">
<span class="text-sm text-gray-400 dark:text-gray-500">No permissions assigned</span>
</template>
</div>
</div>
</template>
<template x-if="roles.length === 0 && !loading">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield', 'w-12 h-12 mx-auto mb-4 opacity-50')"></span>
<p>No roles found. Create a custom role to get started.</p>
</div>
</template>
</div>
<!-- Create/Edit Role Modal -->
{% call modal_simple('roleModal', 'editingRole ? "Edit Role" : "Create Role"', 'showRoleModal') %}
<div class="space-y-4">
<!-- Role Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role Name</label>
<input
type="text"
x-model="roleForm.name"
placeholder="e.g. Content Editor"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<!-- Permission Matrix -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
<template x-for="(perms, category) in permissionsByCategory" :key="category">
<div class="border-b last:border-b-0 dark:border-gray-600">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category"></span>
<button
@click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="isCategoryFullySelected(category) ? 'Deselect All' : 'Select All'"
></button>
</div>
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
<template x-for="perm in perms" :key="perm.id">
<label class="flex items-center gap-2 py-1 cursor-pointer">
<input
type="checkbox"
:value="perm.id"
:checked="roleForm.permissions.includes(perm.id)"
@change="togglePermission(perm.id)"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="perm.id"></span>
</label>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button
@click="showRoleModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:text-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>Cancel</button>
<button
@click="saveRole()"
:disabled="saving || !roleForm.name.trim()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="saving" class="inline-block animate-spin mr-1">&#8635;</span>
<span x-text="editingRole ? 'Update Role' : 'Create Role'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script>
function storeRoles() {
return {
roles: [],
loading: true,
error: false,
saving: false,
showRoleModal: false,
editingRole: null,
roleForm: { name: '', permissions: [] },
permissionsByCategory: {},
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() {
await this.loadPermissions();
await this.loadRoles();
},
async loadPermissions() {
try {
const resp = await fetch(`/api/v1/store/team/me/permissions`, {
headers: { 'Authorization': `Bearer ${this.getToken()}` }
});
// We need a permissions-by-category endpoint; for now use a simple list
// Group known permissions by category prefix
const allPerms = window.USER_PERMISSIONS || [];
this.permissionsByCategory = this.groupPermissions(allPerms);
} catch (e) {
console.warn('Could not load permission categories:', e);
}
},
groupPermissions(permIds) {
// Known permission categories from the codebase
const knownPerms = [
'dashboard.view',
'settings.view', 'settings.edit', 'settings.theme', 'settings.domains',
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.import', 'products.export',
'orders.view', 'orders.edit', 'orders.cancel', 'orders.refund',
'customers.view', 'customers.edit', 'customers.delete', 'customers.export',
'stock.view', 'stock.edit', 'stock.transfer',
'team.view', 'team.invite', 'team.edit', 'team.remove',
'analytics.view', 'analytics.export',
'messaging.view_messages', 'messaging.send_messages', 'messaging.manage_templates',
'billing.view_tiers', 'billing.manage_tiers', 'billing.view_subscriptions', 'billing.manage_subscriptions', 'billing.view_invoices',
'cms.view_pages', 'cms.manage_pages', 'cms.view_media', 'cms.manage_media', 'cms.manage_themes',
'loyalty.view_programs', 'loyalty.manage_programs', 'loyalty.view_rewards', 'loyalty.manage_rewards',
'cart.view', 'cart.manage',
];
const groups = {};
for (const perm of knownPerms) {
const cat = perm.split('.')[0];
if (!groups[cat]) groups[cat] = [];
groups[cat].push({ id: perm });
}
return groups;
},
async loadRoles() {
this.loading = true;
this.error = false;
try {
const resp = await fetch(`/api/v1/store/team/roles`, {
headers: { 'Authorization': `Bearer ${this.getToken()}` }
});
if (!resp.ok) throw new Error('Failed to load roles');
const data = await resp.json();
this.roles = data.roles || [];
} catch (e) {
this.error = true;
console.error('Error loading roles:', e);
} finally {
this.loading = false;
}
},
isPresetRole(name) {
return this.presetRoles.includes(name.toLowerCase());
},
openCreateModal() {
this.editingRole = null;
this.roleForm = { name: '', permissions: [] };
this.showRoleModal = true;
},
openEditModal(role) {
this.editingRole = role;
this.roleForm = {
name: role.name,
permissions: [...(role.permissions || [])],
};
this.showRoleModal = true;
},
togglePermission(permId) {
const idx = this.roleForm.permissions.indexOf(permId);
if (idx >= 0) {
this.roleForm.permissions.splice(idx, 1);
} else {
this.roleForm.permissions.push(permId);
}
},
toggleCategory(category) {
const perms = this.permissionsByCategory[category] || [];
const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) {
this.roleForm.permissions = this.roleForm.permissions.filter(id => !permIds.includes(id));
} else {
for (const id of permIds) {
if (!this.roleForm.permissions.includes(id)) {
this.roleForm.permissions.push(id);
}
}
}
},
isCategoryFullySelected(category) {
const perms = this.permissionsByCategory[category] || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
},
async saveRole() {
this.saving = true;
try {
const url = this.editingRole
? `/api/v1/store/team/roles/${this.editingRole.id}`
: '/api/v1/store/team/roles';
const method = this.editingRole ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${this.getToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(this.roleForm),
});
if (!resp.ok) {
const err = await resp.json();
alert(err.detail || 'Failed to save role');
return;
}
this.showRoleModal = false;
await this.loadRoles();
} catch (e) {
console.error('Error saving role:', e);
alert('Failed to save role');
} finally {
this.saving = false;
}
},
async confirmDelete(role) {
if (!confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
try {
const resp = await fetch(`/api/v1/store/team/roles/${role.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${this.getToken()}` },
});
if (!resp.ok) {
const err = await resp.json();
alert(err.detail || 'Failed to delete role');
return;
}
await this.loadRoles();
} catch (e) {
console.error('Error deleting role:', e);
alert('Failed to delete role');
}
},
getToken() {
return document.cookie.split(';')
.map(c => c.trim())
.find(c => c.startsWith('store_token='))
?.split('=')[1] || '';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,355 @@
# app/modules/tenancy/tests/integration/test_store_team_roles_api.py
"""
Integration tests for store team role CRUD API endpoints.
Tests the role management endpoints at:
/api/v1/store/team/roles
Authentication: Overrides get_current_store_from_cookie_or_header to return
a UserContext with the correct token_store_id. The test user is the merchant
owner, so all permission checks pass (owner bypass).
"""
import uuid
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Role, Store, StoreUser, User
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/store/team"
@pytest.fixture
def role_owner(db):
"""Create a store owner user for role tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"roleowner_{uid}@test.com",
username=f"roleowner_{uid}",
hashed_password=auth.hash_password("rolepass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def role_merchant(db, role_owner):
"""Create a merchant owned by role_owner."""
merchant = Merchant(
name="Role Test Merchant",
owner_user_id=role_owner.id,
contact_email=role_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def role_store(db, role_merchant):
"""Create a store for role tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=role_merchant.id,
store_code=f"ROLETEST_{uid.upper()}",
subdomain=f"roletest{uid}",
name=f"Role Test Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def role_store_user(db, role_store, role_owner):
"""Create a StoreUser association for the owner."""
store_user = StoreUser(
store_id=role_store.id,
user_id=role_owner.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
@pytest.fixture
def role_auth(role_owner, role_store, role_store_user):
"""Override auth dependency to simulate authenticated store owner.
Overrides get_current_store_from_cookie_or_header so that both
require_store_owner and require_store_permission(...) inner functions
receive the correct UserContext. The owner bypass ensures all
permission checks pass.
"""
user_context = UserContext(
id=role_owner.id,
email=role_owner.email,
username=role_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=role_store.id,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
@pytest.fixture
def existing_custom_role(db, role_store):
"""Create an existing custom role for update/delete tests."""
role = Role(
store_id=role_store.id,
name="test_custom_role",
permissions=["products.view", "orders.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
# ============================================================================
# GET /team/roles
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestListRoles:
"""Tests for GET /api/v1/store/team/roles."""
def test_list_roles_creates_defaults(self, client, role_auth, role_store):
"""GET /roles returns default preset roles when none exist."""
response = client.get(f"{BASE}/roles", headers=role_auth)
assert response.status_code == 200
data = response.json()
assert "roles" in data
assert data["total"] >= 5
role_names = {r["name"] for r in data["roles"]}
assert "manager" in role_names
assert "staff" in role_names
assert "support" in role_names
assert "viewer" in role_names
assert "marketing" in role_names
def test_list_roles_includes_custom(self, client, role_auth, existing_custom_role):
"""GET /roles includes custom roles alongside presets."""
response = client.get(f"{BASE}/roles", headers=role_auth)
assert response.status_code == 200
data = response.json()
role_names = {r["name"] for r in data["roles"]}
assert "test_custom_role" in role_names
def test_list_roles_response_shape(self, client, role_auth, existing_custom_role):
"""Each role in the response has expected fields."""
response = client.get(f"{BASE}/roles", headers=role_auth)
assert response.status_code == 200
role = response.json()["roles"][0]
assert "id" in role
assert "name" in role
assert "permissions" in role
assert isinstance(role["permissions"], list)
# ============================================================================
# POST /team/roles
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestCreateRole:
"""Tests for POST /api/v1/store/team/roles."""
def test_create_custom_role_success(self, client, role_auth, role_store):
"""POST /roles creates a new custom role."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "api_test_role",
"permissions": ["products.view", "orders.view"],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "api_test_role"
assert "products.view" in data["permissions"]
assert "orders.view" in data["permissions"]
def test_create_role_preset_name_rejected(self, client, role_auth):
"""POST /roles rejects preset role names."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "manager",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()
def test_create_role_duplicate_name_rejected(
self, client, role_auth, existing_custom_role
):
"""POST /roles rejects duplicate role names."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "test_custom_role",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
assert "already exists" in response.json()["message"].lower()
def test_create_role_invalid_permissions(self, client, role_auth):
"""POST /roles rejects invalid permission IDs."""
response = client.post(
f"{BASE}/roles",
headers=role_auth,
json={
"name": "bad_perms_role",
"permissions": ["totally.fake.permission"],
},
)
assert response.status_code == 422
assert "invalid" in response.json()["message"].lower()
# ============================================================================
# PUT /team/roles/{role_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestUpdateRole:
"""Tests for PUT /api/v1/store/team/roles/{role_id}."""
def test_update_role_name(self, client, role_auth, existing_custom_role):
"""PUT /roles/{id} updates the role name."""
response = client.put(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
json={
"name": "renamed_role",
"permissions": existing_custom_role.permissions,
},
)
assert response.status_code == 200
assert response.json()["name"] == "renamed_role"
def test_update_role_permissions(self, client, role_auth, existing_custom_role):
"""PUT /roles/{id} updates permissions."""
response = client.put(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
json={
"name": existing_custom_role.name,
"permissions": ["products.view", "products.edit", "orders.view", "orders.edit"],
},
)
assert response.status_code == 200
data = response.json()
assert "products.edit" in data["permissions"]
assert "orders.edit" in data["permissions"]
def test_update_nonexistent_role(self, client, role_auth):
"""PUT /roles/{id} returns 400 for non-existent role."""
response = client.put(
f"{BASE}/roles/99999",
headers=role_auth,
json={"name": "whatever", "permissions": []},
)
assert response.status_code == 422
def test_update_role_rename_to_preset_rejected(
self, client, role_auth, existing_custom_role
):
"""PUT /roles/{id} rejects renaming to a preset name."""
response = client.put(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
json={"name": "staff", "permissions": existing_custom_role.permissions},
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()
# ============================================================================
# DELETE /team/roles/{role_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestDeleteRole:
"""Tests for DELETE /api/v1/store/team/roles/{role_id}."""
def test_delete_custom_role_success(self, client, role_auth, existing_custom_role, db):
"""DELETE /roles/{id} removes a custom role."""
response = client.delete(
f"{BASE}/roles/{existing_custom_role.id}",
headers=role_auth,
)
assert response.status_code == 204
# Verify role is deleted
db.expire_all()
deleted = db.query(Role).filter(Role.id == existing_custom_role.id).first()
assert deleted is None
def test_delete_nonexistent_role(self, client, role_auth):
"""DELETE /roles/{id} returns 400 for non-existent role."""
response = client.delete(
f"{BASE}/roles/99999",
headers=role_auth,
)
assert response.status_code == 422
def test_delete_preset_role_rejected(self, client, role_auth, role_store, db):
"""DELETE /roles/{id} rejects deleting preset roles."""
preset_role = Role(
store_id=role_store.id,
name="staff",
permissions=["orders.view"],
)
db.add(preset_role)
db.commit()
db.refresh(preset_role)
response = client.delete(
f"{BASE}/roles/{preset_role.id}",
headers=role_auth,
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()

View File

@@ -9,6 +9,7 @@ import pytest
from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
InvalidRoleException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, User
@@ -417,3 +418,313 @@ class TestStoreTeamServiceGetRoles:
for role in roles:
assert "permissions" in role
assert isinstance(role["permissions"], list)
# =============================================================================
# CUSTOM ROLE CRUD TESTS
# =============================================================================
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceCreateCustomRole:
"""Test suite for creating custom roles."""
def test_create_custom_role_success(self, db, team_store, test_user):
"""Test creating a custom role with valid permissions."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="content_editor",
permissions=["products.view", "products.edit"],
actor_user_id=test_user.id,
)
db.commit()
assert role is not None
assert role.name == "content_editor"
assert role.store_id == team_store.id
assert "products.view" in role.permissions
assert "products.edit" in role.permissions
def test_create_role_with_preset_name_raises_error(self, db, team_store, test_user):
"""Test creating a role with a preset name raises ValueError."""
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="manager",
permissions=["products.view"],
actor_user_id=test_user.id,
)
def test_create_role_with_preset_name_case_insensitive(self, db, team_store, test_user):
"""Test preset name check is case-insensitive."""
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="Manager",
permissions=["products.view"],
actor_user_id=test_user.id,
)
def test_create_duplicate_role_name_raises_error(self, db, team_store, test_user):
"""Test creating a role with duplicate name raises ValueError."""
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="editor",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="already exists"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="editor",
permissions=["orders.view"],
actor_user_id=test_user.id,
)
def test_create_role_with_invalid_permissions_raises_error(self, db, team_store, test_user):
"""Test creating a role with invalid permission IDs raises ValueError."""
with pytest.raises(InvalidRoleException, match="Invalid permission IDs"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="invalid_role",
permissions=["completely.fake.permission"],
actor_user_id=test_user.id,
)
def test_create_role_with_empty_permissions(self, db, team_store, test_user):
"""Test creating a role with empty permissions list."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="observer",
permissions=[],
actor_user_id=test_user.id,
)
db.commit()
assert role is not None
assert role.name == "observer"
assert role.permissions == []
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceUpdateCustomRole:
"""Test suite for updating roles."""
def test_update_role_name(self, db, team_store, test_user):
"""Test updating a role's name."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="old_name",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
updated = store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
name="new_name",
actor_user_id=test_user.id,
)
db.commit()
assert updated.name == "new_name"
def test_update_role_permissions(self, db, team_store, test_user):
"""Test updating a role's permissions."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="perm_test",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
updated = store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
permissions=["products.view", "products.edit", "orders.view"],
actor_user_id=test_user.id,
)
db.commit()
assert "products.edit" in updated.permissions
assert "orders.view" in updated.permissions
def test_update_role_not_found_raises_error(self, db, team_store):
"""Test updating a non-existent role raises ValueError."""
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=99999,
name="new_name",
)
def test_update_role_rename_to_preset_raises_error(self, db, team_store, test_user):
"""Test renaming to a preset name raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="custom_role",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
name="staff",
)
def test_update_role_with_invalid_permissions(self, db, team_store, test_user):
"""Test updating with invalid permissions raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="to_update",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="Invalid permission IDs"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
permissions=["nonexistent.perm"],
)
def test_update_role_duplicate_name_raises_error(self, db, team_store, test_user):
"""Test renaming to an existing role name raises ValueError."""
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="role_a",
permissions=["products.view"],
actor_user_id=test_user.id,
)
role_b = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="role_b",
permissions=["orders.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="already exists"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role_b.id,
name="role_a",
)
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceDeleteRole:
"""Test suite for deleting roles."""
def test_delete_custom_role_success(self, db, team_store, test_user):
"""Test deleting a custom role."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="to_delete",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
role_id = role.id
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=role_id,
actor_user_id=test_user.id,
)
db.commit()
# Verify role is deleted
deleted = db.query(Role).filter(Role.id == role_id).first()
assert deleted is None
def test_delete_preset_role_raises_error(self, db, team_store):
"""Test deleting a preset role raises ValueError."""
# Ensure default roles exist
store_team_service.get_store_roles(db, team_store.id)
db.flush()
# Find the "staff" preset role
staff_role = (
db.query(Role)
.filter(Role.store_id == team_store.id, Role.name == "staff")
.first()
)
assert staff_role is not None
with pytest.raises(InvalidRoleException, match="preset role"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=staff_role.id,
)
def test_delete_role_not_found_raises_error(self, db, team_store):
"""Test deleting non-existent role raises ValueError."""
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=99999,
)
def test_delete_role_with_members_raises_error(self, db, team_store, test_user, other_user, auth_manager):
"""Test deleting a role that has assigned members raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="in_use_role",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
# Assign the role to a store user
store_user = StoreUser(
store_id=team_store.id,
user_id=other_user.id,
role_id=role.id,
is_active=True,
invitation_accepted_at=datetime.utcnow(),
)
db.add(store_user)
db.flush()
with pytest.raises(InvalidRoleException, match="team member"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=role.id,
)