Compare commits

..

No commits in common. "0c1fbab0ef44445443b7e79c575905786492c5d8" and "a7d68c0646dd7391d46a71b98c38426e1af4189e" have entirely different histories.

9 changed files with 26 additions and 692 deletions

View File

@ -3,44 +3,11 @@ name: Test Suite
on: on:
push: push:
branches: [main, develop] branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_run: workflow_run:
workflows: ["Deploy Development", "Deploy Production"] workflows: ["Deploy Development", "Deploy Production"]
types: [completed] types: [completed]
jobs: jobs:
# Wie Mitai-Jinkendo: pytest im laufenden backend-Container (Python aus Image, gleiche DB wie Deploy).
pytest-backend:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Backend pytest im deployten Container
run: |
EVENT_NAME="${{ github.event_name }}"
REF_NAME="${{ github.ref_name }}"
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
APP_DIR="/home/lars/docker/shinkan"
COMPOSE_FILE="docker-compose.yml"
if [ "$EVENT_NAME" = "workflow_run" ]; then
if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then
APP_DIR="/home/lars/docker/shinkan-dev"
COMPOSE_FILE="docker-compose.dev-env.yml"
fi
elif [ "$REF_NAME" = "develop" ]; then
APP_DIR="/home/lars/docker/shinkan-dev"
COMPOSE_FILE="docker-compose.dev-env.yml"
fi
cd "$APP_DIR"
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
pip install -r /app/requirements-dev.txt &&
cd /app &&
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
"
lint-backend: lint-backend:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -205,8 +172,7 @@ jobs:
- name: Upload test screenshots - name: Upload test screenshots
if: failure() if: failure()
# v4 ist auf Gitea (GHES-kompatibles Actions-Backend) oft nicht verfügbar uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with: with:
name: playwright-screenshots name: playwright-screenshots
path: screenshots/ path: screenshots/

View File

@ -29,10 +29,8 @@ class PasswordResetConfirm(BaseModel):
new_password: str new_password: str
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):
"""Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration.""" name: str
email: Optional[str] = None
name: str = Field(min_length=2, max_length=200)
email: EmailStr
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None

View File

@ -1,10 +1,3 @@
[pytest] [pytest]
testpaths = tests testpaths = tests
pythonpath = . pythonpath = .
python_files = test_*.py
python_functions = test_*
addopts = -q --tb=short
markers =
smoke: Schnelle Kern-Regression.
integration: PostgreSQL-Mandanten-Integration (ACCESS_LAYER_INTEGRATION=1).
slow: Lange/schwere Tests; in CI wie Mitai-Jinkendo ausgeschlossen (Auswahl: not slow).

View File

@ -3,14 +3,14 @@ Profile Management Endpoints for Mitai Jinkendo
Handles profile CRUD operations for both admin and current user. Handles profile CRUD operations for both admin and current user.
""" """
import secrets import uuid
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin from auth import require_auth
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin 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 tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate from models import ProfileCreate, ProfileUpdate
@ -87,51 +87,17 @@ def list_profiles(session=Depends(require_auth)):
@router.post("/profiles") @router.post("/profiles")
def create_profile(p: ProfileCreate, session=Depends(require_auth)): def create_profile(p: ProfileCreate, session=Depends(require_auth)):
""" """Create new profile (admin)."""
Neues Profil anlegen nur Plattform-Admin. pid = str(uuid.uuid4())
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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
"SELECT id FROM profiles WHERE email IS NOT NULL AND lower(trim(email)) = %s", VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
(email_norm,), (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
) with get_db() as conn:
if cur.fetchone(): cur = get_cursor(conn)
raise HTTPException(status_code=409, detail="E-Mail bereits registriert") cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
return r2d(cur.fetchone())
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: def profile_document(pid: str) -> dict:
@ -148,12 +114,8 @@ def profile_document(pid: str) -> dict:
@router.get("/profiles/{pid}") @router.get("/profiles/{pid}")
def get_profile(pid: str, session=Depends(require_auth)): def get_profile(pid: str, _session=Depends(require_auth)):
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT).""" """Get profile by ID."""
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) return profile_document(pid)
def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict: def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict:
"""Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile.""" """Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile."""
@ -287,13 +249,7 @@ def update_profile(pid: str, p: ProfileUpdate, tenant: TenantContext = Depends(g
@router.delete("/profiles/{pid}") @router.delete("/profiles/{pid}")
def delete_profile(pid: str, session=Depends(require_auth)): def delete_profile(pid: str, session=Depends(require_auth)):
"""Profil löschen — nur Plattform-Admin; letztes Profil wird geschützt.""" """Delete profile (admin)."""
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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT COUNT(*) as count FROM profiles") cur.execute("SELECT COUNT(*) as count FROM profiles")

View File

@ -1,323 +0,0 @@
"""
Cross-Tenant-Integrationstests (PostgreSQL).
Voraussetzungen:
- Migrierte Datenbank (wie lokaler Docker-Postgres).
- ACCESS_LAYER_INTEGRATION=1 oder true
- DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD passend setzen
(lokal z. B. DB_HOST=localhost DB_PORT=5434 bei docker-compose Port-Mapping).
Aufruf aus backend/:
set ACCESS_LAYER_INTEGRATION=1
pytest tests/test_access_layer_integration.py -v -m integration
main wird mit SKIP_DB_MIGRATE=1 importiert, damit beim Testlauf keine Migrationen erneut laufen.
"""
from __future__ import annotations
import os
import uuid
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
def _integration_enabled() -> bool:
return os.getenv("ACCESS_LAYER_INTEGRATION", "").strip().lower() in ("1", "true", "yes")
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _integration_enabled(),
reason="ACCESS_LAYER_INTEGRATION=1 setzen und PostgreSQL (DB_*) konfigurieren",
),
]
def _db_ping() -> bool:
try:
from db import get_db, get_cursor
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1 AS ok")
row = cur.fetchone()
return row is not None and row.get("ok") == 1
except Exception:
return False
@pytest.fixture(scope="module")
def integration_app():
"""FastAPI-App ohne erneuten Migrationslauf beim Import."""
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
return app
@pytest.fixture(scope="module")
def db_ready():
if not _db_ping():
pytest.skip("PostgreSQL nicht erreichbar (DB_HOST/DB_PORT/… prüfen)")
@dataclass
class CrossTenantFixture:
suffix: str
club_a_id: int
club_b_id: int
user_a_id: int
user_b_id: int
token_a: str
token_b: str
exercise_official_id: int
exercise_club_b_id: int
@pytest.fixture
def cross_tenant(integration_app, db_ready) -> CrossTenantFixture:
from auth import hash_pin, make_token
from db import get_db, get_cursor
suffix = uuid.uuid4().hex[:10]
email_a = f"access_layer_it_{suffix}_a@test.local"
email_b = f"access_layer_it_{suffix}_b@test.local"
name_a = f"Club A User {suffix}"
name_b = f"Club B User {suffix}"
club_a_name = f"access_layer_it_club_a_{suffix}"
club_b_name = f"access_layer_it_club_b_{suffix}"
token_a = f"it-token-a-{suffix}"
token_b = f"it-token-b-{suffix}"
pin_hash = hash_pin("integration-test-pin")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"INSERT INTO clubs (name, abbreviation, status) VALUES (%s, %s, %s) RETURNING id",
(club_a_name, "ITA", "active"),
)
club_a_id = int(cur.fetchone()["id"])
cur.execute(
"INSERT INTO clubs (name, abbreviation, status) VALUES (%s, %s, %s) RETURNING id",
(club_b_name, "ITB", "active"),
)
club_b_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO profiles (email, pin_hash, name, role, active_club_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(email_a, pin_hash, name_a, "trainer", club_a_id),
)
user_a_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO profiles (email, pin_hash, name, role, active_club_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(email_b, pin_hash, name_b, "trainer", club_b_id),
)
user_b_id = int(cur.fetchone()["id"])
cur.execute(
"INSERT INTO club_members (profile_id, club_id, status) VALUES (%s, %s, %s)",
(user_a_id, club_a_id, "active"),
)
cur.execute(
"INSERT INTO club_members (profile_id, club_id, status) VALUES (%s, %s, %s)",
(user_b_id, club_b_id, "active"),
)
cur.execute(
"""
INSERT INTO sessions (profile_id, token, expires_at)
VALUES (%s, %s, NOW() + INTERVAL '2 days')
""",
(user_a_id, token_a),
)
cur.execute(
"""
INSERT INTO sessions (profile_id, token, expires_at)
VALUES (%s, %s, NOW() + INTERVAL '2 days')
""",
(user_b_id, token_b),
)
title_official = f"access_layer_it_official_{suffix}"
title_club_b = f"access_layer_it_club_b_only_{suffix}"
cur.execute(
"""
INSERT INTO exercises
(title, summary, goal, execution, visibility, status, created_by, club_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
title_official,
None,
"Goal",
"Execution",
"official",
"approved",
user_b_id,
None,
),
)
exercise_official_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO exercises
(title, summary, goal, execution, visibility, status, created_by, club_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
title_club_b,
None,
"Goal",
"Execution",
"club",
"approved",
user_b_id,
club_b_id,
),
)
exercise_club_b_id = int(cur.fetchone()["id"])
conn.commit()
fx = CrossTenantFixture(
suffix=suffix,
club_a_id=club_a_id,
club_b_id=club_b_id,
user_a_id=user_a_id,
user_b_id=user_b_id,
token_a=token_a,
token_b=token_b,
exercise_official_id=exercise_official_id,
exercise_club_b_id=exercise_club_b_id,
)
try:
yield fx
finally:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM exercises WHERE id IN (%s, %s)",
(exercise_official_id, exercise_club_b_id),
)
cur.execute("DELETE FROM sessions WHERE token IN (%s, %s)", (token_a, token_b))
cur.execute(
"DELETE FROM club_members WHERE profile_id IN (%s, %s)",
(user_a_id, user_b_id),
)
cur.execute("DELETE FROM profiles WHERE id IN (%s, %s)", (user_a_id, user_b_id))
cur.execute("DELETE FROM clubs WHERE id IN (%s, %s)", (club_a_id, club_b_id))
conn.commit()
def test_list_hides_foreign_club_exercise_same_active_header(
integration_app, cross_tenant: CrossTenantFixture
):
"""Nutzer nur Verein A sieht keine Vereins-Übung von Verein B (aktiver Header = A)."""
client = TestClient(integration_app)
r = client.get(
"/api/exercises",
params={"search": f"access_layer_it_club_b_only_{cross_tenant.suffix}", "limit": 50},
headers={
"X-Auth-Token": cross_tenant.token_a,
"X-Active-Club-Id": str(cross_tenant.club_a_id),
},
)
assert r.status_code == 200, r.text
ids = [row["id"] for row in r.json()]
assert cross_tenant.exercise_club_b_id not in ids
def test_list_shows_official_exercise_for_foreign_user(
integration_app, cross_tenant: CrossTenantFixture
):
"""Offizielle Übung ist für alle authentifizierten Nutzer sichtbar."""
client = TestClient(integration_app)
r = client.get(
"/api/exercises",
params={"search": f"access_layer_it_official_{cross_tenant.suffix}", "limit": 50},
headers={
"X-Auth-Token": cross_tenant.token_a,
"X-Active-Club-Id": str(cross_tenant.club_a_id),
},
)
assert r.status_code == 200, r.text
ids = [row["id"] for row in r.json()]
assert cross_tenant.exercise_official_id in ids
def test_detail_forbidden_foreign_club_exercise(
integration_app, cross_tenant: CrossTenantFixture
):
"""Detail: keine Mitgliedschaft im Übungs-Verein → 403."""
client = TestClient(integration_app)
r = client.get(
f"/api/exercises/{cross_tenant.exercise_club_b_id}",
headers={
"X-Auth-Token": cross_tenant.token_a,
"X-Active-Club-Id": str(cross_tenant.club_a_id),
},
)
assert r.status_code == 403
def test_member_club_b_can_read_own_club_exercise(
integration_app, cross_tenant: CrossTenantFixture
):
client = TestClient(integration_app)
r = client.get(
f"/api/exercises/{cross_tenant.exercise_club_b_id}",
headers={
"X-Auth-Token": cross_tenant.token_b,
"X-Active-Club-Id": str(cross_tenant.club_b_id),
},
)
assert r.status_code == 200
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

@ -1,193 +0,0 @@
"""
Zugriffsrechte Profile: POST/GET/DELETE /api/profiles/{pid}.
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
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
)
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

View File

@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.36" APP_VERSION = "0.8.29"
BUILD_DATE = "2026-05-05" BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041" DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests "profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL) "tenant_context": "1.0.3", # pytest: backend/tests/test_access_layer.py (Visibility-SQL, Header)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "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) "club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
@ -27,60 +27,6 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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",
"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",
"changes": [
"CI pytest-backend wie Mitai-Jinkendo: docker compose exec backend (deployte Stacks shinkan / shinkan-dev); keine Runner-Python/Postgres-Service",
"pytest.ini: Marker smoke/slow, addopts/-q wie Schwesterprojekt; CI: -m \"not slow\", ACCESS_LAYER_STRICT + Integration im Container",
],
},
{
"version": "0.8.32",
"date": "2026-05-05",
"changes": [
"CI pytest-backend: kein setup-python — venv aus System-python3 (arm64/Debian Self-Host, Raspi)",
],
},
{
"version": "0.8.31",
"date": "2026-05-05",
"changes": [
"CI (Gitea): Job pytest-backend — Checkout, Postgres-Service, run_migrations, pytest (+ ACCESS_LAYER_INTEGRATION), ACCESS_LAYER_STRICT auf Router-Hinweis-Script",
"Workflow test.yml: pull_request auf main/develop zusätzlich zu push",
],
},
{
"version": "0.8.30",
"date": "2026-05-05",
"changes": [
"pytest: optionale PostgreSQL-Integration tests/test_access_layer_integration.py (Cross-Verein Übungen Liste/Detail; ACCESS_LAYER_INTEGRATION=1)",
"pytest.ini: Marker „integration“",
],
},
{ {
"version": "0.8.29", "version": "0.8.29",
"date": "2026-05-05", "date": "2026-05-05",

View File

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

View File

@ -76,12 +76,8 @@ test('4. Navigation zu Vereine', async ({ page }) => {
await page.locator('.bottom-nav a[href="/clubs"]').click(); await page.locator('.bottom-nav a[href="/clubs"]').click();
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter // Prüfe ob Vereine-Seite geladen
// Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen. await expect(page.locator('h1, h2, .page-title')).toContainText(/vereine|clubs/i, { timeout: 5000 });
await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
timeout: 5000,
});
await page.screenshot({ path: 'screenshots/04-vereine.png' }); await page.screenshot({ path: 'screenshots/04-vereine.png' });
console.log('✓ Vereine-Seite erreichbar'); console.log('✓ Vereine-Seite erreichbar');
@ -119,20 +115,15 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
await login(page); await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 }); await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
).toBeVisible({ timeout: 10000 });
await page.reload({ waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Auth lädt erst nach Spinner nicht auf /login stranden (stabiler als Button „Login“-Tab auf Login-Screen) // Auth lädt erst nach Spinner nicht auf /login stranden (stabiler als Button „Login“-Tab auf Login-Screen)
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 20000 }); await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
await expect(page).not.toHaveURL(/\/login(?:\/|$|\?|#)/, { timeout: 20000 }); await expect(page).not.toHaveURL('**/login', { timeout: 20000 });
await expect( await expect(page.locator('h1').filter({ hasText: /^Dashboard$/ })).toBeVisible({
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }), timeout: 10000,
).toBeVisible({
timeout: 20000,
}); });
await page.screenshot({ path: 'screenshots/07-nach-reload.png' }); await page.screenshot({ path: 'screenshots/07-nach-reload.png' });