from datetime import timedelta import pytest from django.utils import timezone from rest_framework.test import APIClient from apps.clients.models import Client from apps.projects.models import Project, ProjectMembership from apps.tags.models import Tag from apps.users.models import User from apps.workspaces.models import Workspace, WorkspaceMembership @pytest.fixture() def api_client(): return APIClient() def _user(index: int) -> User: return User.objects.create_user( mobile=f"091255500{index:02d}", password="secret123", first_name=f"User{index}", ) @pytest.fixture() def owner(db): return _user(1) @pytest.fixture() def admin(db): return _user(2) @pytest.fixture() def member(db): return _user(3) @pytest.fixture() def guest(db): return _user(4) @pytest.fixture() def extra_owner(db): return _user(5) @pytest.fixture() def workspace(owner, admin, member, guest): workspace = Workspace.objects.create(name="Ops", description="", owner=owner) WorkspaceMembership.objects.create( workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True, ) WorkspaceMembership.objects.create( workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True, ) WorkspaceMembership.objects.create( workspace=workspace, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True, ) return workspace @pytest.fixture() def project(workspace, owner, member): project = Project.objects.create(workspace=workspace, name="Alpha", description="") ProjectMembership.objects.create( project=project, user=owner, role=ProjectMembership.Role.MANAGER, is_active=True, ) ProjectMembership.objects.create( project=project, user=member, role=ProjectMembership.Role.MANAGER, is_active=True, ) return project def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project): client = Client.objects.create(workspace=workspace, name="Existing Client", notes="") api_client.force_authenticate(user=member) client_response = api_client.post( "/api/clients/", {"workspace_id": str(workspace.id), "name": "Acme", "notes": ""}, format="json", ) update_client_response = api_client.patch( f"/api/clients/{client.id}/", {"name": "Updated"}, format="json", ) delete_client_response = api_client.delete(f"/api/clients/{client.id}/") project_response = api_client.post( "/api/projects/", {"workspace": str(workspace.id), "name": "Beta", "description": "", "client": None}, format="json", ) update_project_response = api_client.patch( f"/api/projects/{project.id}/", {"description": "Blocked edit"}, format="json", ) archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/") delete_project_response = api_client.delete(f"/api/projects/{project.id}/") membership_response = api_client.post( "/api/memberships/", { "project_id": str(project.id), "user_id": str(workspace.owner_id), "role": ProjectMembership.Role.MEMBER, }, format="json", ) assert client_response.status_code == 403 assert update_client_response.status_code == 403 assert delete_client_response.status_code == 403 assert project_response.status_code == 403 assert update_project_response.status_code == 403 assert archive_project_response.status_code == 403 assert delete_project_response.status_code == 403 assert membership_response.status_code == 403 def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace): tag = Tag.objects.create(workspace=workspace, name="Existing", color="#000000") api_client.force_authenticate(user=member) create_tag_response = api_client.post( "/api/tags/", {"workspace_id": str(workspace.id), "name": "New Tag", "color": "#ffffff"}, format="json", ) update_tag_response = api_client.patch( f"/api/tags/{tag.id}/", {"name": "Changed"}, format="json", ) delete_tag_response = api_client.delete(f"/api/tags/{tag.id}/") now = timezone.now() create_entry_response = api_client.post( "/api/time-entries/", { "workspace_id": str(workspace.id), "start_time": now.isoformat(), "end_time": (now + timedelta(hours=1)).isoformat(), "description": "Focus block", }, format="json", ) assert create_tag_response.status_code == 201 assert update_tag_response.status_code == 403 assert delete_tag_response.status_code == 403 assert create_entry_response.status_code == 201 entry_id = create_entry_response.data["id"] update_entry_response = api_client.patch( f"/api/time-entries/{entry_id}/", {"description": "Updated focus block"}, format="json", ) delete_entry_response = api_client.delete(f"/api/time-entries/{entry_id}/") assert update_entry_response.status_code == 200 assert delete_entry_response.status_code == 204 def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, workspace, project): Client.objects.create(workspace=workspace, name="Visible Client", notes="") Tag.objects.create(workspace=workspace, name="Visible Tag", color="#123456") api_client.force_authenticate(user=guest) list_clients_response = api_client.get(f"/api/clients/?workspace={workspace.id}") list_projects_response = api_client.get(f"/api/projects/?workspace={workspace.id}") create_tag_response = api_client.post( "/api/tags/", {"workspace_id": str(workspace.id), "name": "Blocked", "color": "#ffffff"}, format="json", ) create_entry_response = api_client.post( "/api/time-entries/", { "workspace_id": str(workspace.id), "start_time": timezone.now().isoformat(), "description": "Blocked guest entry", }, format="json", ) edit_project_response = api_client.patch( f"/api/projects/{project.id}/", {"description": "Blocked"}, format="json", ) assert list_clients_response.status_code == 200 assert list_projects_response.status_code == 200 assert create_tag_response.status_code == 403 assert create_entry_response.status_code == 403 assert edit_project_response.status_code == 403 def test_member_project_manager_cannot_edit_project(api_client, member, project): api_client.force_authenticate(user=member) response = api_client.patch( f"/api/projects/{project.id}/", {"description": "Still blocked"}, format="json", ) assert response.status_code == 403 def test_admin_cannot_change_owner_membership_but_canonical_owner_can( api_client, owner, admin, extra_owner, workspace ): extra_owner_membership = WorkspaceMembership.objects.create( workspace=workspace, user=extra_owner, role=WorkspaceMembership.Role.OWNER, is_active=True, ) api_client.force_authenticate(user=admin) admin_response = api_client.patch( f"/api/workspace-memberships/{extra_owner_membership.id}/", {"role": WorkspaceMembership.Role.ADMIN}, format="json", ) api_client.force_authenticate(user=owner) owner_response = api_client.patch( f"/api/workspace-memberships/{extra_owner_membership.id}/", {"role": WorkspaceMembership.Role.ADMIN}, format="json", ) assert admin_response.status_code == 403 assert owner_response.status_code == 200 def test_admin_cannot_add_or_change_admin_memberships(api_client, owner, admin, member, workspace): admin_membership = WorkspaceMembership.objects.get(workspace=workspace, user=admin, is_deleted=False) api_client.force_authenticate(user=admin) create_response = api_client.post( "/api/workspace-memberships/", { "workspace": str(workspace.id), "user": str(member.id), "role": WorkspaceMembership.Role.ADMIN, }, format="json", ) update_response = api_client.patch( f"/api/workspace-memberships/{admin_membership.id}/", {"role": WorkspaceMembership.Role.MEMBER}, format="json", ) delete_response = api_client.delete(f"/api/workspace-memberships/{admin_membership.id}/") assert create_response.status_code == 403 assert update_response.status_code == 403 assert delete_response.status_code == 403 def test_admin_can_delete_only_owned_clients_tags_and_projects(api_client, owner, admin, workspace): api_client.force_authenticate(user=owner) owner_client_response = api_client.post( "/api/clients/", {"workspace_id": str(workspace.id), "name": "Owner Client", "notes": ""}, format="json", ) owner_tag_response = api_client.post( "/api/tags/", {"workspace_id": str(workspace.id), "name": "Owner Tag", "color": "#123456"}, format="json", ) owner_project_response = api_client.post( "/api/projects/", {"workspace": str(workspace.id), "name": "Owner Project", "description": "", "client": None}, format="json", ) api_client.force_authenticate(user=admin) admin_client_response = api_client.post( "/api/clients/", {"workspace_id": str(workspace.id), "name": "Admin Client", "notes": ""}, format="json", ) admin_tag_response = api_client.post( "/api/tags/", {"workspace_id": str(workspace.id), "name": "Admin Tag", "color": "#654321"}, format="json", ) admin_project_response = api_client.post( "/api/projects/", {"workspace": str(workspace.id), "name": "Admin Project", "description": "", "client": None}, format="json", ) delete_owner_client = api_client.delete(f"/api/clients/{owner_client_response.data['id']}/") delete_owner_tag = api_client.delete(f"/api/tags/{owner_tag_response.data['id']}/") delete_owner_project = api_client.delete(f"/api/projects/{owner_project_response.data['id']}/") delete_admin_client = api_client.delete(f"/api/clients/{admin_client_response.data['id']}/") delete_admin_tag = api_client.delete(f"/api/tags/{admin_tag_response.data['id']}/") delete_admin_project = api_client.delete(f"/api/projects/{admin_project_response.data['id']}/") assert delete_owner_client.status_code == 403 assert delete_owner_tag.status_code == 403 assert delete_owner_project.status_code in {403, 404} assert delete_admin_client.status_code == 204 assert delete_admin_tag.status_code == 204 assert delete_admin_project.status_code == 204