""" 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