2025-12-18 23:33:32 +01:00
|
|
|
"""
|
|
|
|
|
Permission and Role-Based Access Control Tests
|
|
|
|
|
|
|
|
|
|
These tests verify that:
|
|
|
|
|
1. Users can only access endpoints they have permission for
|
|
|
|
|
2. Users without proper roles are denied access (403)
|
|
|
|
|
3. Unauthenticated users are denied access (401)
|
|
|
|
|
4. The permission system cannot be bypassed
|
|
|
|
|
"""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from models import Permission
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Role Assignment Tests
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
class TestRoleAssignment:
|
|
|
|
|
"""Test that roles are properly assigned and returned."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_regular_user_has_correct_roles(self, client_factory, regular_user):
|
|
|
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
|
|
|
response = await client.get("/api/auth/me")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert "regular" in data["roles"]
|
|
|
|
|
assert "admin" not in data["roles"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_admin_user_has_correct_roles(self, client_factory, admin_user):
|
|
|
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
|
|
|
|
response = await client.get("/api/auth/me")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert "admin" in data["roles"]
|
|
|
|
|
assert "regular" not in data["roles"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-21 21:54:26 +01:00
|
|
|
async def test_regular_user_has_correct_permissions(
|
|
|
|
|
self, client_factory, regular_user
|
|
|
|
|
):
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
|
|
|
response = await client.get("/api/auth/me")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
data = response.json()
|
|
|
|
|
permissions = data["permissions"]
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-22 20:23:41 +01:00
|
|
|
# Should have profile and exchange permissions
|
2025-12-22 18:07:14 +01:00
|
|
|
assert Permission.MANAGE_OWN_PROFILE.value in permissions
|
2025-12-22 20:23:41 +01:00
|
|
|
assert Permission.CREATE_EXCHANGE.value in permissions
|
|
|
|
|
assert Permission.VIEW_OWN_EXCHANGES.value in permissions
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Should NOT have audit permission
|
|
|
|
|
assert Permission.VIEW_AUDIT.value not in permissions
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_admin_user_has_correct_permissions(self, client_factory, admin_user):
|
|
|
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
|
|
|
|
response = await client.get("/api/auth/me")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
data = response.json()
|
|
|
|
|
permissions = data["permissions"]
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Should have audit permission
|
|
|
|
|
assert Permission.VIEW_AUDIT.value in permissions
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-22 20:23:41 +01:00
|
|
|
# Should NOT have exchange permissions (those are for regular users)
|
|
|
|
|
assert Permission.CREATE_EXCHANGE.value not in permissions
|
2025-12-18 23:33:32 +01:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-21 21:54:26 +01:00
|
|
|
async def test_user_with_no_roles_has_no_permissions(
|
|
|
|
|
self, client_factory, user_no_roles
|
|
|
|
|
):
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
|
|
|
|
|
response = await client.get("/api/auth/me")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
data = response.json()
|
|
|
|
|
assert data["roles"] == []
|
|
|
|
|
assert data["permissions"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Audit Endpoint Access Tests
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
class TestAuditAccess:
|
|
|
|
|
"""Test access control for audit endpoints."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-22 18:07:14 +01:00
|
|
|
async def test_admin_can_view_price_history(self, client_factory, admin_user):
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
response = await client.get("/api/audit/price-history")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 200
|
2025-12-22 18:07:14 +01:00
|
|
|
# Returns a list
|
|
|
|
|
assert isinstance(response.json(), list)
|
2025-12-18 23:33:32 +01:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-22 18:07:14 +01:00
|
|
|
async def test_regular_user_cannot_view_price_history(
|
2025-12-21 21:54:26 +01:00
|
|
|
self, client_factory, regular_user
|
|
|
|
|
):
|
2025-12-18 23:33:32 +01:00
|
|
|
"""Regular users should be forbidden from audit endpoints."""
|
|
|
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
response = await client.get("/api/audit/price-history")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 403
|
|
|
|
|
assert "permission" in response.json()["detail"].lower()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-21 21:54:26 +01:00
|
|
|
async def test_user_without_roles_cannot_view_audit(
|
|
|
|
|
self, client_factory, user_no_roles
|
|
|
|
|
):
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
response = await client.get("/api/audit/price-history")
|
2025-12-21 22:53:54 +01:00
|
|
|
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-22 18:07:14 +01:00
|
|
|
async def test_unauthenticated_cannot_view_price_history(self, client):
|
|
|
|
|
response = await client.get("/api/audit/price-history")
|
2025-12-21 22:53:54 +01:00
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Offensive Security Tests - Bypass Attempts
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
class TestSecurityBypassAttempts:
|
|
|
|
|
"""
|
|
|
|
|
Offensive tests that attempt to bypass security controls.
|
|
|
|
|
These simulate potential attack vectors.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-21 21:54:26 +01:00
|
|
|
async def test_cannot_access_audit_with_forged_role_claim(
|
|
|
|
|
self, client_factory, regular_user
|
|
|
|
|
):
|
2025-12-18 23:33:32 +01:00
|
|
|
"""
|
|
|
|
|
Attempt to access audit by somehow claiming admin role.
|
|
|
|
|
The server should verify roles from DB, not trust client claims.
|
|
|
|
|
"""
|
|
|
|
|
# Regular user tries to access audit endpoint
|
|
|
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
response = await client.get("/api/audit/price-history")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Should be denied regardless of any manipulation attempts
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-22 18:07:14 +01:00
|
|
|
async def test_cannot_access_with_expired_session(self, client_factory):
|
2025-12-18 23:33:32 +01:00
|
|
|
"""Test that invalid/expired tokens are rejected."""
|
|
|
|
|
fake_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5OTk5IiwiZXhwIjoxfQ.invalid"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies={"auth_token": fake_token}) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
response = await client.get("/api/profile")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2025-12-21 21:54:26 +01:00
|
|
|
async def test_cannot_access_with_tampered_token(
|
|
|
|
|
self, client_factory, regular_user
|
|
|
|
|
):
|
2025-12-18 23:33:32 +01:00
|
|
|
"""Test that tokens signed with wrong key are rejected."""
|
|
|
|
|
# Take a valid token and modify it
|
|
|
|
|
original_token = regular_user["cookies"].get("auth_token", "")
|
|
|
|
|
if original_token:
|
|
|
|
|
tampered_token = original_token[:-5] + "XXXXX"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
|
|
|
|
async with client_factory.create(
|
|
|
|
|
cookies={"auth_token": tampered_token}
|
|
|
|
|
) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
response = await client.get("/api/profile")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cannot_escalate_to_admin_via_registration(self, client_factory):
|
|
|
|
|
"""
|
|
|
|
|
Test that new registrations cannot claim admin role.
|
|
|
|
|
New users should only get 'regular' role by default.
|
|
|
|
|
"""
|
2025-12-20 11:43:32 +01:00
|
|
|
from models import ROLE_REGULAR
|
2025-12-21 21:54:26 +01:00
|
|
|
from tests.conftest import create_user_with_roles
|
|
|
|
|
from tests.helpers import create_invite_for_godfather, unique_email
|
|
|
|
|
|
2025-12-20 11:12:11 +01:00
|
|
|
async with client_factory.get_db_session() as db:
|
2025-12-21 21:54:26 +01:00
|
|
|
godfather = await create_user_with_roles(
|
|
|
|
|
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
|
|
|
|
)
|
2025-12-20 11:43:32 +01:00
|
|
|
invite_code = await create_invite_for_godfather(db, godfather.id)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
response = await client_factory.post(
|
|
|
|
|
"/api/auth/register",
|
2025-12-20 11:12:11 +01:00
|
|
|
json={
|
|
|
|
|
"email": unique_email(),
|
|
|
|
|
"password": "password123",
|
|
|
|
|
"invite_identifier": invite_code,
|
|
|
|
|
},
|
2025-12-18 23:33:32 +01:00
|
|
|
)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Should only have regular role, not admin
|
|
|
|
|
assert "admin" not in data["roles"]
|
|
|
|
|
assert Permission.VIEW_AUDIT.value not in data["permissions"]
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Try to access audit with this new user
|
|
|
|
|
async with client_factory.create(cookies=dict(response.cookies)) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
audit_response = await client.get("/api/audit/price-history")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert audit_response.status_code == 403
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_deleted_user_token_is_invalid(self, client_factory):
|
|
|
|
|
"""
|
|
|
|
|
If a user is deleted, their token should no longer work.
|
|
|
|
|
This tests that tokens are validated against current DB state.
|
|
|
|
|
"""
|
|
|
|
|
from sqlalchemy import delete
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
from models import User
|
2025-12-21 21:54:26 +01:00
|
|
|
from tests.helpers import unique_email
|
|
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
email = unique_email("deleted")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Create and login user
|
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
|
|
|
from tests.conftest import create_user_with_roles
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
user = await create_user_with_roles(db, email, "password123", ["regular"])
|
|
|
|
|
user_id = user.id
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
login_response = await client_factory.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": email, "password": "password123"},
|
|
|
|
|
)
|
|
|
|
|
cookies = dict(login_response.cookies)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Delete the user from DB
|
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
|
|
|
await db.execute(delete(User).where(User.id == user_id))
|
|
|
|
|
await db.commit()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Try to use the old token
|
|
|
|
|
async with client_factory.create(cookies=cookies) as client:
|
|
|
|
|
response = await client.get("/api/auth/me")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_role_change_reflected_immediately(self, client_factory):
|
|
|
|
|
"""
|
|
|
|
|
If a user's role is changed, the change should be reflected
|
|
|
|
|
in subsequent requests (no stale permission cache).
|
|
|
|
|
"""
|
|
|
|
|
from sqlalchemy import select
|
2025-12-21 21:54:26 +01:00
|
|
|
|
|
|
|
|
from models import Role, User
|
|
|
|
|
from tests.helpers import unique_email
|
|
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
email = unique_email("rolechange")
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Create regular user
|
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
|
|
|
from tests.conftest import create_user_with_roles
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
await create_user_with_roles(db, email, "password123", ["regular"])
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
login_response = await client_factory.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": email, "password": "password123"},
|
|
|
|
|
)
|
|
|
|
|
cookies = dict(login_response.cookies)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-22 18:07:14 +01:00
|
|
|
# Verify can access profile but not audit
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies=cookies) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
assert (await client.get("/api/profile")).status_code == 200
|
|
|
|
|
assert (await client.get("/api/audit/price-history")).status_code == 403
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Change user's role from regular to admin
|
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
|
|
|
result = await db.execute(select(User).where(User.email == email))
|
|
|
|
|
user = result.scalar_one()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
result = await db.execute(select(Role).where(Role.name == "admin"))
|
|
|
|
|
admin_role = result.scalar_one()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
|
|
|
|
user.roles = [admin_role] # Replace roles with admin only
|
2025-12-18 23:33:32 +01:00
|
|
|
await db.commit()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-22 18:07:14 +01:00
|
|
|
# Now should have audit access but not profile access (admin doesn't have MANAGE_OWN_PROFILE)
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.create(cookies=cookies) as client:
|
2025-12-22 18:07:14 +01:00
|
|
|
assert (await client.get("/api/audit/price-history")).status_code == 200
|
|
|
|
|
assert (await client.get("/api/profile")).status_code == 403
|