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
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:
parent
f03330bf77
commit
caab9f2863
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
131
backend/tests/test_profiles_read_access.py
Normal file
131
backend/tests/test_profiles_read_access.py
Normal 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
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user