feat: update application version to 0.8.35 and enhance profile access controls
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 34s

- Bumped application version to 0.8.35 in both backend and frontend files.
- Updated profile retrieval and deletion endpoints to restrict access to the profile owner or platform admins, returning a 403 status for unauthorized access.
- Added integration tests to verify access control for profile retrieval.
- Enhanced changelog to reflect the new version and changes made in this release.
This commit is contained in:
Lars 2026-05-05 22:57:42 +02:00
parent f03330bf77
commit caab9f2863
5 changed files with 188 additions and 6 deletions

View File

@ -114,8 +114,12 @@ def profile_document(pid: str) -> dict:
@router.get("/profiles/{pid}")
def get_profile(pid: str, _session=Depends(require_auth)):
"""Get profile by ID."""
def get_profile(pid: str, session=Depends(require_auth)):
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""
viewer = session["profile_id"]
role = session.get("role") or ""
if str(viewer) != str(pid) and not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Profil")
return profile_document(pid)
def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict:
"""Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile."""
@ -249,7 +253,13 @@ def update_profile(pid: str, p: ProfileUpdate, tenant: TenantContext = Depends(g
@router.delete("/profiles/{pid}")
def delete_profile(pid: str, session=Depends(require_auth)):
"""Delete profile (admin)."""
"""Profil löschen — nur Plattform-Admin; letztes Profil wird geschützt."""
role = session.get("role") or ""
if not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Administratoren dürfen Profile löschen",
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT COUNT(*) as count FROM profiles")

View File

@ -295,3 +295,29 @@ def test_member_club_b_can_read_own_club_exercise(
body = r.json()
assert body["id"] == cross_tenant.exercise_club_b_id
assert body["visibility"] == "club"
def test_get_profile_forbidden_other_user_integration(
integration_app, cross_tenant: CrossTenantFixture
):
"""GET /profiles/{id}: Fremdes Profil → 403 (PostgreSQL-Integration)."""
client = TestClient(integration_app)
r = client.get(
f"/api/profiles/{cross_tenant.user_b_id}",
headers={"X-Auth-Token": cross_tenant.token_a},
)
assert r.status_code == 403
def test_get_profile_ok_own_integration(
integration_app, cross_tenant: CrossTenantFixture
):
client = TestClient(integration_app)
r = client.get(
f"/api/profiles/{cross_tenant.user_a_id}",
headers={"X-Auth-Token": cross_tenant.token_a},
)
assert r.status_code == 200
body = r.json()
assert body["id"] == cross_tenant.user_a_id
assert "pin_hash" not in body

View File

@ -0,0 +1,131 @@
"""
Zugriffsrechte Profile: GET /api/profiles/{pid} und DELETE /api/profiles/{pid}.
TestClient + Dependency-Override für Sessions; GET mockt profile_document, DELETE mockt DB,
damit keine echte Datenbank nötig ist.
"""
from __future__ import annotations
import os
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_auth_override():
yield
app.dependency_overrides.pop(require_auth, None)
def test_get_profile_forbidden_other_user(client: TestClient) -> None:
def auth_viewer() -> dict:
return {"profile_id": 42, "role": "trainer"}
app.dependency_overrides[require_auth] = auth_viewer
with patch("routers.profiles.profile_document") as pd_mock:
r = client.get("/api/profiles/99", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 403
assert pd_mock.call_count == 0
def test_get_profile_ok_same_numeric_id(client: TestClient) -> None:
payload = {"id": 42, "name": "Self"}
def auth_self() -> dict:
return {"profile_id": 42, "role": "trainer"}
app.dependency_overrides[require_auth] = auth_self
with patch("routers.profiles.profile_document", return_value=payload) as pd_mock:
r = client.get("/api/profiles/42", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
assert r.json() == payload
pd_mock.assert_called_once_with("42")
def test_get_profile_ok_same_string_session_id(client: TestClient) -> None:
"""Session liefert profile_id als String (Backward-Compatibility)."""
payload = {"id": 7, "name": "S"}
def auth_self_str() -> dict:
return {"profile_id": "7", "role": "user"}
app.dependency_overrides[require_auth] = auth_self_str
with patch("routers.profiles.profile_document", return_value=payload):
r = client.get("/api/profiles/7", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
@pytest.mark.parametrize("admin_role", ["admin", "superadmin"])
def test_get_profile_ok_platform_admin_views_other(client: TestClient, admin_role: str) -> None:
payload = {"id": 99, "name": "Other"}
def auth_admin() -> dict:
return {"profile_id": 1, "role": admin_role}
app.dependency_overrides[require_auth] = auth_admin
with patch("routers.profiles.profile_document", return_value=payload) as pd_mock:
r = client.get("/api/profiles/99", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
assert r.json() == payload
pd_mock.assert_called_once_with("99")
def test_delete_profile_forbidden_non_admin(client: TestClient) -> None:
def auth_trainer() -> dict:
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = auth_trainer
with patch("routers.profiles.get_db") as mock_get_db:
r = client.delete("/api/profiles/1", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 403
mock_get_db.assert_not_called()
def test_delete_profile_admin_last_profile_protected(client: TestClient) -> None:
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {"count": 1}
app.dependency_overrides[require_auth] = lambda: {"profile_id": 99, "role": "admin"}
with patch("routers.profiles.get_db", return_value=mock_cm), patch(
"routers.profiles.get_cursor", return_value=mock_cur
):
r = client.delete("/api/profiles/5", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 400
def test_delete_profile_admin_ok_when_multiple_profiles(client: TestClient) -> None:
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {"count": 4}
app.dependency_overrides[require_auth] = lambda: {"profile_id": 99, "role": "superadmin"}
with patch("routers.profiles.get_db", return_value=mock_cm), patch(
"routers.profiles.get_cursor", return_value=mock_cur
):
r = client.delete("/api/profiles/12", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
assert r.json() == {"ok": True}
calls = mock_cur.execute.call_args_list
assert any(
args and "DELETE FROM profiles" in str(args[0][0]) for args in calls
)

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.33"
APP_VERSION = "0.8.35"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden
"profiles": "1.5.1", # GET/DELETE /profiles/{id} nur Admin bei DELETE; test_profiles_read_access.py
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -27,6 +27,21 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.35",
"date": "2026-05-05",
"changes": [
"DELETE /api/profiles/{pid}: nur Plattform-Admin (403 sonst); Unit-Tests mit gemockter DB",
],
},
{
"version": "0.8.34",
"date": "2026-05-05",
"changes": [
"GET /api/profiles/{pid}: Zugriff nur eigenes Profil oder Plattform-Admin (403 sonst); Unit-Tests ohne DB; zwei Integrationstests mit PostgreSQL",
"Integration: zwei zusätzliche Profil-Lese-Tests in test_access_layer_integration",
],
},
{
"version": "0.8.33",
"date": "2026-05-05",

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.33"
export const APP_VERSION = "0.8.35"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {