From 35b14fe1a6e16ce4146cea25671188d920ccb601 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 23:01:14 +0200 Subject: [PATCH] feat: update application version to 0.8.36 and enhance profile creation process - Bumped application version to 0.8.36 in both backend and frontend files. - Updated the ProfileCreate model to require name and email fields, ensuring schema compliance. - Implemented a new POST /api/profiles endpoint restricted to platform admins, utilizing a random PIN for user setup. - Added integration tests for profile creation, including checks for unauthorized access and duplicate email handling. - Enhanced changelog to reflect the new version and changes made in this release. --- backend/models.py | 6 +- backend/routers/profiles.py | 56 ++++++++++++++---- backend/tests/test_profiles_read_access.py | 66 +++++++++++++++++++++- backend/version.py | 12 +++- frontend/src/version.js | 2 +- 5 files changed, 124 insertions(+), 18 deletions(-) diff --git a/backend/models.py b/backend/models.py index 881bfa6..791d48c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -29,8 +29,10 @@ class PasswordResetConfirm(BaseModel): new_password: str class ProfileCreate(BaseModel): - name: str - email: Optional[str] = None + """Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration.""" + + name: str = Field(min_length=2, max_length=200) + email: EmailStr class ProfileUpdate(BaseModel): name: Optional[str] = None diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index afca884..9825854 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -3,14 +3,14 @@ Profile Management Endpoints for Mitai Jinkendo Handles profile CRUD operations for both admin and current user. """ -import uuid +import secrets from typing import Optional from datetime import datetime from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, hash_pin from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context from models import ProfileCreate, ProfileUpdate @@ -87,17 +87,51 @@ def list_profiles(session=Depends(require_auth)): @router.post("/profiles") def create_profile(p: ProfileCreate, session=Depends(require_auth)): - """Create new profile (admin).""" - pid = str(uuid.uuid4()) + """ + Neues Profil anlegen — nur Plattform-Admin. + + Nutzt gleiches Kernschema wie Registrierung (SERIAL id); PIN wird zufällig gesetzt — Nutzer + setzt das Passwort über „Passwort vergessen“ oder ein späteres Admin-Tool. + """ + role = session.get("role") or "" + if not is_platform_admin(role): + raise HTTPException( + status_code=403, + detail="Nur Plattform-Administratoren dürfen Profile anlegen", + ) + + email_norm = str(p.email).strip().lower() + name_s = (p.name or "").strip() + + pin_hash = hash_pin(secrets.token_urlsafe(24)) + with get_db() as conn: cur = get_cursor(conn) - cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""", - (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct)) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) - return r2d(cur.fetchone()) + cur.execute( + "SELECT id FROM profiles WHERE email IS NOT NULL AND lower(trim(email)) = %s", + (email_norm,), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="E-Mail bereits registriert") + + cur.execute( + """ + INSERT INTO profiles ( + name, email, pin_hash, auth_type, role, tier, + email_verified, created_at, updated_at + ) + VALUES (%s, %s, %s, 'email', 'user', 'free', TRUE, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, + (name_s, email_norm, pin_hash), + ) + row = cur.fetchone() + new_id = row["id"] if row else None + if new_id is None: + raise HTTPException(status_code=500, detail="Profil konnte nicht angelegt werden") + + return profile_document(str(new_id)) def profile_document(pid: str) -> dict: diff --git a/backend/tests/test_profiles_read_access.py b/backend/tests/test_profiles_read_access.py index b3fecb6..35d5bf9 100644 --- a/backend/tests/test_profiles_read_access.py +++ b/backend/tests/test_profiles_read_access.py @@ -1,7 +1,7 @@ """ -Zugriffsrechte Profile: GET /api/profiles/{pid} und DELETE /api/profiles/{pid}. +Zugriffsrechte Profile: POST/GET/DELETE /api/profiles/{pid}. -TestClient + Dependency-Override für Sessions; GET mockt profile_document, DELETE mockt DB, +TestClient + Dependency-Override für Sessions; GET mockt profile_document, DELETE/POST mocken DB, damit keine echte Datenbank nötig ist. """ from __future__ import annotations @@ -129,3 +129,65 @@ def test_delete_profile_admin_ok_when_multiple_profiles(client: TestClient) -> N assert any( args and "DELETE FROM profiles" in str(args[0][0]) for args in calls ) + + +def test_create_profile_forbidden_non_admin(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + with patch("routers.profiles.get_db") as mock_get_db: + r = client.post( + "/api/profiles", + json={"name": "Neu", "email": "neu@example.com"}, + headers={"X-Auth-Token": "dummy"}, + ) + assert r.status_code == 403 + mock_get_db.assert_not_called() + + +def test_create_profile_admin_duplicate_email(client: TestClient) -> None: + mock_cm = MagicMock() + mock_conn = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + mock_cur = MagicMock() + mock_cur.fetchone.return_value = {"id": 99} + + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "admin"} + with patch("routers.profiles.get_db", return_value=mock_cm), patch( + "routers.profiles.get_cursor", return_value=mock_cur + ), patch("routers.profiles.hash_pin", return_value="hashed"): + r = client.post( + "/api/profiles", + json={"name": "Dup", "email": "dup@example.com"}, + headers={"X-Auth-Token": "dummy"}, + ) + assert r.status_code == 409 + + +def test_create_profile_admin_success(client: TestClient) -> None: + mock_cm = MagicMock() + mock_conn = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + mock_cur = MagicMock() + mock_cur.fetchone.side_effect = [None, {"id": 501}] + + doc = {"id": 501, "name": "Nu User", "email": "nu@example.com"} + + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "superadmin"} + with patch("routers.profiles.get_db", return_value=mock_cm), patch( + "routers.profiles.get_cursor", return_value=mock_cur + ), patch("routers.profiles.profile_document", return_value=doc), patch( + "routers.profiles.hash_pin", return_value="hashed" + ): + r = client.post( + "/api/profiles", + json={"name": "Nu User", "email": "nu@example.com"}, + headers={"X-Auth-Token": "dummy"}, + ) + assert r.status_code == 200 + assert r.json() == doc + + insert_calls = [ + c.args[0] for c in mock_cur.execute.call_args_list if c.args and "INSERT INTO profiles" in str(c.args[0]) + ] + assert len(insert_calls) >= 1 diff --git a/backend/version.py b/backend/version.py index 4fc093e..b4065d0 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,12 +1,12 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.35" +APP_VERSION = "0.8.36" 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.5.1", # GET/DELETE /profiles/{id} nur Admin bei DELETE; test_profiles_read_access.py + "profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests "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,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.36", + "date": "2026-05-05", + "changes": [ + "POST /api/profiles: nur Plattform-Admin; Anlage schema-konform (SERIAL, E-Mail, temporärer PIN-Hash); ProfileCreate mit Pflichtfeldern name + email", + "pytest: POST-Szenarien in test_profiles_read_access.py", + ], + }, { "version": "0.8.35", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index 6bde9f1..561f453 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.35" +export const APP_VERSION = "0.8.36" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {