Compare commits

...

7 Commits

Author SHA1 Message Date
0c1fbab0ef Merge pull request 'Mandantenfähigkeit V1.1' (#11) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 11s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 22s
Reviewed-on: #11
2026-05-05 23:11:49 +02:00
14745b347d fix: update test workflow and improve smoke tests
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 44s
Test Suite / pytest-backend (pull_request) Successful in 8s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 6s
Test Suite / playwright-tests (pull_request) Successful in 22s
- Changed the artifact upload action in the CI workflow from v4 to v3 due to compatibility issues with Gitea.
- Enhanced the smoke test for the clubs page to check the URL and visibility of the primary heading, ensuring more robust validation.
- Updated session persistence test to verify the visibility of the dashboard heading after a page reload, improving test reliability.
2026-05-05 23:03:40 +02:00
35b14fe1a6 feat: update application version to 0.8.36 and enhance profile creation process
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 37s
- 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.
2026-05-05 23:01:14 +02:00
caab9f2863 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.
2026-05-05 22:57:42 +02:00
f03330bf77 feat: update application version to 0.8.33 and enhance CI workflow
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 5s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 39s
- Bumped application version to 0.8.33 in both backend and frontend files.
- Refactored pytest-backend job in CI workflow to run tests within the deployed backend container, eliminating the need for a separate Python/Postgres service.
- Updated pytest.ini to include new test markers for smoke and slow tests, and adjusted default options for pytest execution.
- Enhanced changelog to reflect the new version and changes made in this release.
2026-05-05 22:51:59 +02:00
61e3b3a6b1 feat: update application version to 0.8.31 and enhance CI workflow
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Failing after 3s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 34s
- Bumped application version to 0.8.31 in both backend and frontend files.
- Added pytest-backend job to the CI workflow for PostgreSQL integration testing, including database migrations and access layer checks.
- Updated test.yml to trigger on pull requests to main and develop branches in addition to pushes.
- Updated changelog to reflect the new version and changes made in this release.
2026-05-05 22:47:00 +02:00
347af0c36e feat: update application version to 0.8.30 and add integration test marker
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Failing after 35s
- Bumped application version to 0.8.30 in both backend and frontend files.
- Added a new marker for integration tests in pytest.ini to facilitate PostgreSQL integration testing.
- Updated changelog to reflect the new version and changes made in this release.
2026-05-05 22:34:35 +02:00
9 changed files with 692 additions and 26 deletions

View File

@ -3,11 +3,44 @@ 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
@ -172,7 +205,8 @@ jobs:
- name: Upload test screenshots - name: Upload test screenshots
if: failure() if: failure()
uses: actions/upload-artifact@v4 # v4 ist auf Gitea (GHES-kompatibles Actions-Backend) oft nicht verfügbar
uses: actions/upload-artifact@v3
with: with:
name: playwright-screenshots name: playwright-screenshots
path: screenshots/ path: screenshots/

View File

@ -29,8 +29,10 @@ class PasswordResetConfirm(BaseModel):
new_password: str new_password: str
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):
name: str """Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration."""
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,3 +1,10 @@
[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 uuid import secrets
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 from auth import require_auth, hash_pin
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,17 +87,51 @@ 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).""" """
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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) cur.execute(
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""", "SELECT id FROM profiles WHERE email IS NOT NULL AND lower(trim(email)) = %s",
(pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct)) (email_norm,),
with get_db() as conn: )
cur = get_cursor(conn) if cur.fetchone():
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) raise HTTPException(status_code=409, detail="E-Mail bereits registriert")
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:
@ -114,8 +148,12 @@ 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)):
"""Get profile by ID.""" """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) 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."""
@ -249,7 +287,13 @@ 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)):
"""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: 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

@ -0,0 +1,323 @@
"""
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

@ -0,0 +1,193 @@
"""
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.29" APP_VERSION = "0.8.36"
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.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden "profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests
"tenant_context": "1.0.3", # pytest: backend/tests/test_access_layer.py (Visibility-SQL, Header) "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 "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,6 +27,60 @@ 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.29" export const APP_VERSION = "0.8.36"
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,8 +76,12 @@ 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');
// Prüfe ob Vereine-Seite geladen // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
await expect(page.locator('h1, h2, .page-title')).toContainText(/vereine|clubs/i, { timeout: 5000 }); // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
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');
@ -115,15 +119,20 @@ 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(page.locator('h1').filter({ hasText: /^Dashboard$/ })).toBeVisible({ await expect(
timeout: 10000, page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
).toBeVisible({
timeout: 20000,
}); });
await page.screenshot({ path: 'screenshots/07-nach-reload.png' }); await page.screenshot({ path: 'screenshots/07-nach-reload.png' });