Implement team-based organization for projects with role-based access control: Backend: - Add teams and team_memberships database tables (migrations 009, 009b) - Add Team and TeamMembership ORM models with relationships - Implement TeamAuthorizationService for team-level access control - Add team CRUD, membership, and projects API endpoints - Update project creation to support team assignment Frontend: - Add TeamContext for managing team state with localStorage persistence - Add TeamSelector component for switching between teams - Add TeamsPage, TeamDashboardPage, TeamSettingsPage, TeamMembersPage - Add team API client functions - Update navigation with Teams link Security: - Team role hierarchy: owner > admin > member - Membership checked before system admin fallback - Self-modification prevention for role changes - Email visibility restricted to team admins/owners - Slug validation rejects consecutive hyphens Tests: - Unit tests for TeamAuthorizationService - Integration tests for all team API endpoints
317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""
|
|
Integration tests for Teams API endpoints.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestTeamsCRUD:
|
|
"""Tests for team creation, listing, updating, and deletion."""
|
|
|
|
def test_create_team(self, integration_client, unique_test_id):
|
|
"""Test creating a new team."""
|
|
team_name = f"Test Team {unique_test_id}"
|
|
team_slug = f"test-team-{unique_test_id}"
|
|
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={
|
|
"name": team_name,
|
|
"slug": team_slug,
|
|
"description": "A test team",
|
|
},
|
|
)
|
|
assert response.status_code == 201, f"Failed to create team: {response.text}"
|
|
|
|
data = response.json()
|
|
assert data["name"] == team_name
|
|
assert data["slug"] == team_slug
|
|
assert data["description"] == "A test team"
|
|
assert data["user_role"] == "owner"
|
|
assert data["member_count"] == 1
|
|
assert data["project_count"] == 0
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
|
|
def test_create_team_duplicate_slug(self, integration_client, unique_test_id):
|
|
"""Test that duplicate team slugs are rejected."""
|
|
team_slug = f"dup-team-{unique_test_id}"
|
|
|
|
# Create first team
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "First Team", "slug": team_slug},
|
|
)
|
|
assert response.status_code == 201
|
|
|
|
# Try to create second team with same slug
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Second Team", "slug": team_slug},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "already exists" in response.json()["detail"].lower()
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
|
|
def test_create_team_invalid_slug(self, integration_client):
|
|
"""Test that invalid team slugs are rejected."""
|
|
invalid_slugs = [
|
|
"UPPERCASE",
|
|
"with spaces",
|
|
"-starts-with-hyphen",
|
|
"ends-with-hyphen-",
|
|
"has--double--hyphen",
|
|
]
|
|
|
|
for invalid_slug in invalid_slugs:
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Test", "slug": invalid_slug},
|
|
)
|
|
assert response.status_code == 422, f"Slug '{invalid_slug}' should be invalid"
|
|
|
|
def test_list_teams(self, integration_client, unique_test_id):
|
|
"""Test listing teams the user belongs to."""
|
|
# Create a team
|
|
team_slug = f"list-team-{unique_test_id}"
|
|
integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "List Test Team", "slug": team_slug},
|
|
)
|
|
|
|
# List teams
|
|
response = integration_client.get("/api/v1/teams")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "items" in data
|
|
assert "pagination" in data
|
|
|
|
# Find our team
|
|
team = next((t for t in data["items"] if t["slug"] == team_slug), None)
|
|
assert team is not None
|
|
assert team["name"] == "List Test Team"
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
|
|
def test_get_team(self, integration_client, unique_test_id):
|
|
"""Test getting team details."""
|
|
team_slug = f"get-team-{unique_test_id}"
|
|
integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Get Test Team", "slug": team_slug, "description": "Test"},
|
|
)
|
|
|
|
response = integration_client.get(f"/api/v1/teams/{team_slug}")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["slug"] == team_slug
|
|
assert data["name"] == "Get Test Team"
|
|
assert data["user_role"] == "owner"
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
|
|
def test_get_nonexistent_team(self, integration_client):
|
|
"""Test getting a team that doesn't exist."""
|
|
response = integration_client.get("/api/v1/teams/nonexistent-team-12345")
|
|
assert response.status_code == 404
|
|
|
|
def test_update_team(self, integration_client, unique_test_id):
|
|
"""Test updating team details."""
|
|
team_slug = f"update-team-{unique_test_id}"
|
|
integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Original Name", "slug": team_slug},
|
|
)
|
|
|
|
response = integration_client.put(
|
|
f"/api/v1/teams/{team_slug}",
|
|
json={"name": "Updated Name", "description": "New description"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["name"] == "Updated Name"
|
|
assert data["description"] == "New description"
|
|
assert data["slug"] == team_slug # Slug should not change
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
|
|
def test_delete_team(self, integration_client, unique_test_id):
|
|
"""Test deleting a team."""
|
|
team_slug = f"delete-team-{unique_test_id}"
|
|
integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Delete Test Team", "slug": team_slug},
|
|
)
|
|
|
|
response = integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
assert response.status_code == 204
|
|
|
|
# Verify team is gone
|
|
response = integration_client.get(f"/api/v1/teams/{team_slug}")
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestTeamMembers:
|
|
"""Tests for team membership management."""
|
|
|
|
@pytest.fixture
|
|
def test_team(self, integration_client, unique_test_id):
|
|
"""Create a test team for member tests."""
|
|
team_slug = f"member-team-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Member Test Team", "slug": team_slug},
|
|
)
|
|
assert response.status_code == 201
|
|
|
|
yield team_slug
|
|
|
|
# Cleanup
|
|
try:
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
except Exception:
|
|
pass
|
|
|
|
def test_list_members(self, integration_client, test_team):
|
|
"""Test listing team members."""
|
|
response = integration_client.get(f"/api/v1/teams/{test_team}/members")
|
|
assert response.status_code == 200
|
|
|
|
members = response.json()
|
|
assert len(members) == 1
|
|
assert members[0]["role"] == "owner"
|
|
|
|
def test_owner_is_first_member(self, integration_client, test_team):
|
|
"""Test that the team creator is automatically the owner."""
|
|
response = integration_client.get(f"/api/v1/teams/{test_team}/members")
|
|
members = response.json()
|
|
|
|
assert len(members) >= 1
|
|
owner = next((m for m in members if m["role"] == "owner"), None)
|
|
assert owner is not None
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestTeamProjects:
|
|
"""Tests for team project management."""
|
|
|
|
@pytest.fixture
|
|
def test_team(self, integration_client, unique_test_id):
|
|
"""Create a test team for project tests."""
|
|
team_slug = f"proj-team-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "Project Test Team", "slug": team_slug},
|
|
)
|
|
assert response.status_code == 201
|
|
|
|
data = response.json()
|
|
yield {"slug": team_slug, "id": data["id"]}
|
|
|
|
# Cleanup
|
|
try:
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
except Exception:
|
|
pass
|
|
|
|
def test_list_team_projects_empty(self, integration_client, test_team):
|
|
"""Test listing projects in an empty team."""
|
|
response = integration_client.get(f"/api/v1/teams/{test_team['slug']}/projects")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["items"] == []
|
|
assert data["pagination"]["total"] == 0
|
|
|
|
def test_create_project_in_team(self, integration_client, test_team, unique_test_id):
|
|
"""Test creating a project within a team."""
|
|
project_name = f"team-project-{unique_test_id}"
|
|
|
|
response = integration_client.post(
|
|
"/api/v1/projects",
|
|
json={
|
|
"name": project_name,
|
|
"description": "A team project",
|
|
"team_id": test_team["id"],
|
|
},
|
|
)
|
|
assert response.status_code == 200, f"Failed to create project: {response.text}"
|
|
|
|
data = response.json()
|
|
assert data["team_id"] == test_team["id"]
|
|
assert data["team_slug"] == test_team["slug"]
|
|
|
|
# Verify project appears in team projects list
|
|
response = integration_client.get(f"/api/v1/teams/{test_team['slug']}/projects")
|
|
assert response.status_code == 200
|
|
projects = response.json()["items"]
|
|
assert any(p["name"] == project_name for p in projects)
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/projects/{project_name}")
|
|
|
|
def test_project_team_info_in_response(self, integration_client, test_team, unique_test_id):
|
|
"""Test that project responses include team info."""
|
|
project_name = f"team-info-project-{unique_test_id}"
|
|
|
|
# Create project in team
|
|
integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": project_name, "team_id": test_team["id"]},
|
|
)
|
|
|
|
# Get project and verify team info
|
|
response = integration_client.get(f"/api/v1/projects/{project_name}")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["team_id"] == test_team["id"]
|
|
assert data["team_slug"] == test_team["slug"]
|
|
assert data["team_name"] == "Project Test Team"
|
|
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/projects/{project_name}")
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestTeamAuthorization:
|
|
"""Tests for team-based authorization."""
|
|
|
|
def test_cannot_delete_team_with_projects(self, integration_client, unique_test_id):
|
|
"""Test that teams with projects cannot be deleted."""
|
|
team_slug = f"nodelete-team-{unique_test_id}"
|
|
project_name = f"nodelete-project-{unique_test_id}"
|
|
|
|
# Create team
|
|
response = integration_client.post(
|
|
"/api/v1/teams",
|
|
json={"name": "No Delete Team", "slug": team_slug},
|
|
)
|
|
team_id = response.json()["id"]
|
|
|
|
# Create project in team
|
|
integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": project_name, "team_id": team_id},
|
|
)
|
|
|
|
# Try to delete team - should fail
|
|
response = integration_client.delete(f"/api/v1/teams/{team_slug}")
|
|
assert response.status_code == 400
|
|
assert "project" in response.json()["detail"].lower()
|
|
|
|
# Cleanup - delete project first, then team
|
|
integration_client.delete(f"/api/v1/projects/{project_name}")
|
|
integration_client.delete(f"/api/v1/teams/{team_slug}")
|