From 347af0c36e79ad7f7d8fbf69658a089dafe3351f Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 22:34:35 +0200 Subject: [PATCH] feat: update application version to 0.8.30 and add integration test marker - 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. --- backend/pytest.ini | 2 + .../tests/test_access_layer_integration.py | 297 ++++++++++++++++++ backend/version.py | 12 +- frontend/src/version.js | 2 +- 4 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_access_layer_integration.py diff --git a/backend/pytest.ini b/backend/pytest.ini index 4584de7..8637782 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,3 +1,5 @@ [pytest] testpaths = tests pythonpath = . +markers = + integration: PostgreSQL-Mandanten-Integration (ACCESS_LAYER_INTEGRATION=1) diff --git a/backend/tests/test_access_layer_integration.py b/backend/tests/test_access_layer_integration.py new file mode 100644 index 0000000..c19e446 --- /dev/null +++ b/backend/tests/test_access_layer_integration.py @@ -0,0 +1,297 @@ +""" +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" diff --git a/backend/version.py b/backend/version.py index 82613d5..f97f3d3 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,13 +1,13 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.29" +APP_VERSION = "0.8.30" 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 - "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 "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) @@ -27,6 +27,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "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", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index b6e6200..0491c25 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.29" +export const APP_VERSION = "0.8.30" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {