forked from cardosofelipe/fast-next-template
- Implemented stricter OAuth security measures, including CSRF protection via state parameter validation and redirect_uri checks. - Updated OAuth models to support timezone-aware datetime comparisons, replacing deprecated `utcnow`. - Enhanced logging for malformed Basic auth headers during token, introspect, and revoke requests. - Added allowlist validation for OAuth provider domains to prevent open redirect attacks. - Improved nonce validation for OpenID Connect tokens, ensuring token integrity during Google provider flows. - Updated E2E and unit tests to cover new security features and expanded OAuth state handling scenarios.
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""
|
|
Organization E2E workflow tests with real PostgreSQL.
|
|
|
|
These tests validate complete organization workflows including:
|
|
- Creating organizations (via admin)
|
|
- Viewing user's organizations
|
|
- Organization membership management
|
|
- Organization updates
|
|
|
|
Usage:
|
|
make test-e2e # Run all E2E tests
|
|
"""
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
pytestmark = [
|
|
pytest.mark.e2e,
|
|
pytest.mark.postgres,
|
|
pytest.mark.asyncio,
|
|
]
|
|
|
|
|
|
async def register_and_login(client, email: str, password: str = "SecurePassword123!"): # noqa: S107
|
|
"""Helper to register a user and get tokens."""
|
|
# Register
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": email,
|
|
"password": password,
|
|
"first_name": "Test",
|
|
"last_name": "User",
|
|
},
|
|
)
|
|
|
|
# Login
|
|
login_resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
)
|
|
tokens = login_resp.json()
|
|
return tokens
|
|
|
|
|
|
async def create_superuser_and_login(client, db_session):
|
|
"""Helper to create a superuser directly in DB and login."""
|
|
from app.crud.user import user as user_crud
|
|
from app.schemas.users import UserCreate
|
|
|
|
email = f"admin-{uuid4().hex[:8]}@example.com"
|
|
password = "AdminPassword123!"
|
|
|
|
# Create superuser directly
|
|
user_in = UserCreate(
|
|
email=email,
|
|
password=password,
|
|
first_name="Admin",
|
|
last_name="User",
|
|
is_superuser=True,
|
|
)
|
|
await user_crud.create(db_session, obj_in=user_in)
|
|
|
|
# Login
|
|
login_resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
)
|
|
return login_resp.json(), email
|
|
|
|
|
|
class TestOrganizationWorkflows:
|
|
"""Test organization management workflows."""
|
|
|
|
async def test_user_has_no_organizations_initially(self, e2e_client):
|
|
"""New users should have no organizations."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
response = await e2e_client.get(
|
|
"/api/v1/organizations/me",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 0
|
|
|
|
async def test_get_organizations_requires_auth(self, e2e_client):
|
|
"""Organizations endpoint requires authentication."""
|
|
response = await e2e_client.get("/api/v1/organizations/me")
|
|
assert response.status_code == 401
|
|
|
|
async def test_get_nonexistent_organization(self, e2e_client):
|
|
"""Getting a non-member organization returns 403."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
fake_org_id = str(uuid4())
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{fake_org_id}",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
# Should be 403 (not a member) or 404 (not found)
|
|
assert response.status_code in [403, 404]
|
|
|
|
|
|
class TestOrganizationMembershipWorkflows:
|
|
"""Test organization membership workflows."""
|
|
|
|
async def test_non_member_cannot_view_org_details(self, e2e_client):
|
|
"""Users cannot view organizations they're not members of."""
|
|
# Create two users
|
|
user1_email = f"e2e-user1-{uuid4().hex[:8]}@example.com"
|
|
user2_email = f"e2e-user2-{uuid4().hex[:8]}@example.com"
|
|
|
|
await register_and_login(e2e_client, user1_email)
|
|
user2_tokens = await register_and_login(e2e_client, user2_email)
|
|
|
|
# User2 tries to access a random org ID
|
|
fake_org_id = str(uuid4())
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{fake_org_id}",
|
|
headers={"Authorization": f"Bearer {user2_tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code in [403, 404]
|
|
|
|
async def test_non_member_cannot_view_org_members(self, e2e_client):
|
|
"""Users cannot view members of organizations they don't belong to."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
fake_org_id = str(uuid4())
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{fake_org_id}/members",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code in [403, 404]
|
|
|
|
async def test_non_admin_cannot_update_organization(self, e2e_client):
|
|
"""Regular users cannot update organizations (need admin role)."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
fake_org_id = str(uuid4())
|
|
response = await e2e_client.put(
|
|
f"/api/v1/organizations/{fake_org_id}",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
json={"name": "Updated Name"},
|
|
)
|
|
|
|
assert response.status_code in [403, 404]
|
|
|
|
|
|
class TestOrganizationWithMembers:
|
|
"""Test organization workflows using e2e_org_with_members fixture."""
|
|
|
|
async def test_owner_can_view_organization(self, e2e_client, e2e_org_with_members):
|
|
"""Organization owner can view organization details."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{org['org_id']}",
|
|
headers={"Authorization": f"Bearer {org['owner']['tokens']['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == org["org_id"]
|
|
assert data["name"] == org["org_name"]
|
|
|
|
async def test_member_can_view_organization(self, e2e_client, e2e_org_with_members):
|
|
"""Organization member can view organization details."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{org['org_id']}",
|
|
headers={"Authorization": f"Bearer {org['member']['tokens']['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == org["org_id"]
|
|
|
|
async def test_owner_can_list_members(self, e2e_client, e2e_org_with_members):
|
|
"""Organization owner can list members."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{org['org_id']}/members",
|
|
headers={"Authorization": f"Bearer {org['owner']['tokens']['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Should have owner + member = at least 2 members
|
|
assert len(data) >= 2
|
|
|
|
async def test_member_can_list_members(self, e2e_client, e2e_org_with_members):
|
|
"""Organization member can list members."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{org['org_id']}/members",
|
|
headers={"Authorization": f"Bearer {org['member']['tokens']['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
async def test_owner_appears_in_my_organizations(self, e2e_client, e2e_org_with_members):
|
|
"""Owner sees organization in their organizations list."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
"/api/v1/organizations/me",
|
|
headers={"Authorization": f"Bearer {org['owner']['tokens']['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
org_ids = [o["id"] for o in data]
|
|
assert org["org_id"] in org_ids
|
|
|
|
async def test_member_appears_in_my_organizations(self, e2e_client, e2e_org_with_members):
|
|
"""Member sees organization in their organizations list."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
"/api/v1/organizations/me",
|
|
headers={"Authorization": f"Bearer {org['member']['tokens']['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
org_ids = [o["id"] for o in data]
|
|
assert org["org_id"] in org_ids
|
|
|
|
async def test_owner_can_update_organization(self, e2e_client, e2e_org_with_members):
|
|
"""Organization owner can update organization details."""
|
|
org = e2e_org_with_members
|
|
new_description = f"Updated at {uuid4().hex[:8]}"
|
|
|
|
response = await e2e_client.put(
|
|
f"/api/v1/organizations/{org['org_id']}",
|
|
headers={"Authorization": f"Bearer {org['owner']['tokens']['access_token']}"},
|
|
json={"description": new_description},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["description"] == new_description
|
|
|
|
async def test_member_cannot_update_organization(self, e2e_client, e2e_org_with_members):
|
|
"""Regular member cannot update organization details."""
|
|
org = e2e_org_with_members
|
|
|
|
response = await e2e_client.put(
|
|
f"/api/v1/organizations/{org['org_id']}",
|
|
headers={"Authorization": f"Bearer {org['member']['tokens']['access_token']}"},
|
|
json={"description": "Should fail"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
async def test_non_member_cannot_view_organization(
|
|
self, e2e_client, e2e_org_with_members
|
|
):
|
|
"""Non-members cannot view organization details."""
|
|
org = e2e_org_with_members
|
|
|
|
# Create a new user who is not a member
|
|
non_member_email = f"nonmember-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, non_member_email)
|
|
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/{org['org_id']}",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
async def test_get_organization_by_slug(self, e2e_client, e2e_org_with_members):
|
|
"""Organization can be retrieved by slug."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/organizations/slug/{org['org_slug']}",
|
|
headers={
|
|
"Authorization": f"Bearer {org['owner']['tokens']['access_token']}"
|
|
},
|
|
)
|
|
|
|
# May be 200 or 404/403 depending on implementation
|
|
assert response.status_code in [200, 403, 404]
|
|
|
|
|
|
class TestOrganizationAdminOperations:
|
|
"""Test organization admin operations."""
|
|
|
|
async def test_admin_list_org_members_with_pagination(
|
|
self, e2e_client, e2e_superuser, e2e_org_with_members
|
|
):
|
|
"""Admin can list org members with pagination."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/admin/organizations/{org['org_id']}/members",
|
|
headers={
|
|
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
|
|
},
|
|
params={"page": 1, "limit": 10},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "data" in data
|
|
assert "pagination" in data
|
|
|
|
async def test_admin_list_org_members_filter_active(
|
|
self, e2e_client, e2e_superuser, e2e_org_with_members
|
|
):
|
|
"""Admin can filter org members by active status."""
|
|
org = e2e_org_with_members
|
|
response = await e2e_client.get(
|
|
f"/api/v1/admin/organizations/{org['org_id']}/members",
|
|
headers={
|
|
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
|
|
},
|
|
params={"is_active": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|