From caab9f286358732ce72c6024c3ee6b9aafbe85da Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 22:57:42 +0200 Subject: [PATCH] feat: update application version to 0.8.35 and enhance profile access controls - 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. --- backend/routers/profiles.py | 16 ++- .../tests/test_access_layer_integration.py | 26 ++++ backend/tests/test_profiles_read_access.py | 131 ++++++++++++++++++ backend/version.py | 19 ++- frontend/src/version.js | 2 +- 5 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_profiles_read_access.py diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index b0d8f82..afca884 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -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") diff --git a/backend/tests/test_access_layer_integration.py b/backend/tests/test_access_layer_integration.py index c19e446..26f6d6d 100644 --- a/backend/tests/test_access_layer_integration.py +++ b/backend/tests/test_access_layer_integration.py @@ -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 diff --git a/backend/tests/test_profiles_read_access.py b/backend/tests/test_profiles_read_access.py new file mode 100644 index 0000000..b3fecb6 --- /dev/null +++ b/backend/tests/test_profiles_read_access.py @@ -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 + ) diff --git a/backend/version.py b/backend/version.py index 3c88bac..4fc093e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/version.js b/frontend/src/version.js index b47c1d1..6bde9f1 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -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 = {