Add ruff linter/formatter for Python

- Add ruff as dev dependency
- Configure ruff in pyproject.toml with strict 88-char line limit
- Ignore B008 (FastAPI Depends pattern is standard)
- Allow longer lines in tests for readability
- Fix all lint issues in source files
- Add Makefile targets: lint-backend, format-backend, fix-backend
This commit is contained in:
counterweight 2025-12-21 21:54:26 +01:00
parent 69bc8413e0
commit 6c218130e9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
31 changed files with 1234 additions and 876 deletions

View file

@ -7,15 +7,16 @@ These tests verify that:
3. Unauthenticated users are denied access (401)
4. The permission system cannot be bypassed
"""
import pytest
from models import Permission
# =============================================================================
# Role Assignment Tests
# =============================================================================
class TestRoleAssignment:
"""Test that roles are properly assigned and returned."""
@ -23,7 +24,7 @@ class TestRoleAssignment:
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")
assert response.status_code == 200
data = response.json()
assert "regular" in data["roles"]
@ -33,25 +34,27 @@ class TestRoleAssignment:
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")
assert response.status_code == 200
data = response.json()
assert "admin" in data["roles"]
assert "regular" not in data["roles"]
@pytest.mark.asyncio
async def test_regular_user_has_correct_permissions(self, client_factory, regular_user):
async def test_regular_user_has_correct_permissions(
self, client_factory, regular_user
):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/auth/me")
data = response.json()
permissions = data["permissions"]
# Should have counter and sum permissions
assert Permission.VIEW_COUNTER.value in permissions
assert Permission.INCREMENT_COUNTER.value in permissions
assert Permission.USE_SUM.value in permissions
# Should NOT have audit permission
assert Permission.VIEW_AUDIT.value not in permissions
@ -59,23 +62,25 @@ class TestRoleAssignment:
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")
data = response.json()
permissions = data["permissions"]
# Should have audit permission
assert Permission.VIEW_AUDIT.value in permissions
# Should NOT have counter/sum permissions
assert Permission.VIEW_COUNTER.value not in permissions
assert Permission.INCREMENT_COUNTER.value not in permissions
assert Permission.USE_SUM.value not in permissions
@pytest.mark.asyncio
async def test_user_with_no_roles_has_no_permissions(self, client_factory, user_no_roles):
async def test_user_with_no_roles_has_no_permissions(
self, client_factory, user_no_roles
):
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.get("/api/auth/me")
data = response.json()
assert data["roles"] == []
assert data["permissions"] == []
@ -85,6 +90,7 @@ class TestRoleAssignment:
# Counter Endpoint Access Tests
# =============================================================================
class TestCounterAccess:
"""Test access control for counter endpoints."""
@ -92,15 +98,17 @@ class TestCounterAccess:
async def test_regular_user_can_view_counter(self, client_factory, regular_user):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/counter")
assert response.status_code == 200
assert "value" in response.json()
@pytest.mark.asyncio
async def test_regular_user_can_increment_counter(self, client_factory, regular_user):
async def test_regular_user_can_increment_counter(
self, client_factory, regular_user
):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/counter/increment")
assert response.status_code == 200
assert "value" in response.json()
@ -109,7 +117,7 @@ class TestCounterAccess:
"""Admin users should be forbidden from counter endpoints."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/counter")
assert response.status_code == 403
assert "permission" in response.json()["detail"].lower()
@ -118,15 +126,17 @@ class TestCounterAccess:
"""Admin users should be forbidden from incrementing counter."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post("/api/counter/increment")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_without_roles_cannot_view_counter(self, client_factory, user_no_roles):
async def test_user_without_roles_cannot_view_counter(
self, client_factory, user_no_roles
):
"""Users with no roles should be forbidden."""
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.get("/api/counter")
assert response.status_code == 403
@pytest.mark.asyncio
@ -146,6 +156,7 @@ class TestCounterAccess:
# Sum Endpoint Access Tests
# =============================================================================
class TestSumAccess:
"""Test access control for sum endpoint."""
@ -156,7 +167,7 @@ class TestSumAccess:
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 200
data = response.json()
assert data["result"] == 8
@ -169,17 +180,19 @@ class TestSumAccess:
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_without_roles_cannot_use_sum(self, client_factory, user_no_roles):
async def test_user_without_roles_cannot_use_sum(
self, client_factory, user_no_roles
):
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.post(
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 403
@pytest.mark.asyncio
@ -195,6 +208,7 @@ class TestSumAccess:
# Audit Endpoint Access Tests
# =============================================================================
class TestAuditAccess:
"""Test access control for audit endpoints."""
@ -202,7 +216,7 @@ class TestAuditAccess:
async def test_admin_can_view_counter_audit(self, client_factory, admin_user):
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
assert response.status_code == 200
data = response.json()
assert "records" in data
@ -212,34 +226,40 @@ class TestAuditAccess:
async def test_admin_can_view_sum_audit(self, client_factory, admin_user):
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/sum")
assert response.status_code == 200
data = response.json()
assert "records" in data
assert "total" in data
@pytest.mark.asyncio
async def test_regular_user_cannot_view_counter_audit(self, client_factory, regular_user):
async def test_regular_user_cannot_view_counter_audit(
self, client_factory, regular_user
):
"""Regular users should be forbidden from audit endpoints."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
assert response.status_code == 403
assert "permission" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_regular_user_cannot_view_sum_audit(self, client_factory, regular_user):
async def test_regular_user_cannot_view_sum_audit(
self, client_factory, regular_user
):
"""Regular users should be forbidden from audit endpoints."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/sum")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_without_roles_cannot_view_audit(self, client_factory, user_no_roles):
async def test_user_without_roles_cannot_view_audit(
self, client_factory, user_no_roles
):
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.get("/api/audit/counter")
assert response.status_code == 403
@pytest.mark.asyncio
@ -257,6 +277,7 @@ class TestAuditAccess:
# Offensive Security Tests - Bypass Attempts
# =============================================================================
class TestSecurityBypassAttempts:
"""
Offensive tests that attempt to bypass security controls.
@ -264,7 +285,9 @@ class TestSecurityBypassAttempts:
"""
@pytest.mark.asyncio
async def test_cannot_access_audit_with_forged_role_claim(self, client_factory, regular_user):
async def test_cannot_access_audit_with_forged_role_claim(
self, client_factory, regular_user
):
"""
Attempt to access audit by somehow claiming admin role.
The server should verify roles from DB, not trust client claims.
@ -272,7 +295,7 @@ class TestSecurityBypassAttempts:
# Regular user tries to access audit endpoint
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
# Should be denied regardless of any manipulation attempts
assert response.status_code == 403
@ -280,23 +303,27 @@ class TestSecurityBypassAttempts:
async def test_cannot_access_counter_with_expired_session(self, client_factory):
"""Test that invalid/expired tokens are rejected."""
fake_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5OTk5IiwiZXhwIjoxfQ.invalid"
async with client_factory.create(cookies={"auth_token": fake_token}) as client:
response = await client.get("/api/counter")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_cannot_access_with_tampered_token(self, client_factory, regular_user):
async def test_cannot_access_with_tampered_token(
self, client_factory, regular_user
):
"""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"
async with client_factory.create(cookies={"auth_token": tampered_token}) as client:
async with client_factory.create(
cookies={"auth_token": tampered_token}
) as client:
response = await client.get("/api/counter")
assert response.status_code == 401
@pytest.mark.asyncio
@ -305,14 +332,16 @@ class TestSecurityBypassAttempts:
Test that new registrations cannot claim admin role.
New users should only get 'regular' role by default.
"""
from tests.helpers import unique_email, create_invite_for_godfather
from tests.conftest import create_user_with_roles
from models import ROLE_REGULAR
from tests.conftest import create_user_with_roles
from tests.helpers import create_invite_for_godfather, unique_email
async with client_factory.get_db_session() as db:
godfather = await create_user_with_roles(db, unique_email("gf"), "pass123", [ROLE_REGULAR])
godfather = await create_user_with_roles(
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
)
invite_code = await create_invite_for_godfather(db, godfather.id)
response = await client_factory.post(
"/api/auth/register",
json={
@ -321,18 +350,18 @@ class TestSecurityBypassAttempts:
"invite_identifier": invite_code,
},
)
assert response.status_code == 200
data = response.json()
# Should only have regular role, not admin
assert "admin" not in data["roles"]
assert Permission.VIEW_AUDIT.value not in data["permissions"]
# Try to access audit with this new user
async with client_factory.create(cookies=dict(response.cookies)) as client:
audit_response = await client.get("/api/audit/counter")
assert audit_response.status_code == 403
@pytest.mark.asyncio
@ -341,33 +370,35 @@ class TestSecurityBypassAttempts:
If a user is deleted, their token should no longer work.
This tests that tokens are validated against current DB state.
"""
from tests.helpers import unique_email
from sqlalchemy import delete
from models import User
from tests.helpers import unique_email
email = unique_email("deleted")
# Create and login user
async with client_factory.get_db_session() as db:
from tests.conftest import create_user_with_roles
user = await create_user_with_roles(db, email, "password123", ["regular"])
user_id = user.id
login_response = await client_factory.post(
"/api/auth/login",
json={"email": email, "password": "password123"},
)
cookies = dict(login_response.cookies)
# 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()
# Try to use the old token
async with client_factory.create(cookies=cookies) as client:
response = await client.get("/api/auth/me")
assert response.status_code == 401
@pytest.mark.asyncio
@ -376,42 +407,41 @@ class TestSecurityBypassAttempts:
If a user's role is changed, the change should be reflected
in subsequent requests (no stale permission cache).
"""
from tests.helpers import unique_email
from sqlalchemy import select
from models import User, Role
from models import Role, User
from tests.helpers import unique_email
email = unique_email("rolechange")
# Create regular user
async with client_factory.get_db_session() as db:
from tests.conftest import create_user_with_roles
await create_user_with_roles(db, email, "password123", ["regular"])
login_response = await client_factory.post(
"/api/auth/login",
json={"email": email, "password": "password123"},
)
cookies = dict(login_response.cookies)
# Verify can access counter but not audit
async with client_factory.create(cookies=cookies) as client:
assert (await client.get("/api/counter")).status_code == 200
assert (await client.get("/api/audit/counter")).status_code == 403
# 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()
result = await db.execute(select(Role).where(Role.name == "admin"))
admin_role = result.scalar_one()
result = await db.execute(select(Role).where(Role.name == "regular"))
regular_role = result.scalar_one()
user.roles = [admin_role] # Remove regular, add admin
user.roles = [admin_role] # Replace roles with admin only
await db.commit()
# Now should have audit access but not counter access
async with client_factory.create(cookies=cookies) as client:
assert (await client.get("/api/audit/counter")).status_code == 200
@ -422,6 +452,7 @@ class TestSecurityBypassAttempts:
# Audit Record Tests
# =============================================================================
class TestAuditRecords:
"""Test that actions are properly recorded in audit logs."""
@ -433,15 +464,15 @@ class TestAuditRecords:
# Regular user increments counter
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post("/api/counter/increment")
# Admin checks audit
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
# Find record for our user
records = data["records"]
user_records = [r for r in records if r["user_email"] == regular_user["email"]]
@ -455,18 +486,18 @@ class TestAuditRecords:
# Regular user uses sum
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post("/api/sum", json={"a": 10, "b": 20})
# Admin checks audit
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/sum")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
# Find record with our values
records = data["records"]
matching = [r for r in records if r["a"] == 10 and r["b"] == 20 and r["result"] == 30]
matching = [
r for r in records if r["a"] == 10 and r["b"] == 20 and r["result"] == 30
]
assert len(matching) >= 1