shinkan-jinkendo/backend/tests/test_media_assets_copyright_promotion.py
Lars 34235ef46d
Some checks failed
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Failing after 1m23s
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).

Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
  check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
  write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
  media_asset_rights_declarations (Append-only Audit-Log), Felder
  rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
  POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
  GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
  assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert

Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
  (konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
  Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet

Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt

Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log

Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.

version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00

259 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
P-04: Copyright-Pflicht bei Promotion auf club/official.
PATCH /api/media-assets/{id} und POST /api/media-assets/bulk-patch
lehnen eine Sichtbarkeits-Promotion auf club oder official ab,
wenn keine copyright_notice vorhanden ist.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
from tenant_context import TenantContext, get_tenant_context
_SUPERADMIN_TENANT = TenantContext(
profile_id=1,
global_role="superadmin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
_PRIVATE_ASSET: dict = {
"id": 42,
"visibility": "private",
"club_id": 7,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"copyright_notice": None,
"original_filename": "foto.jpg",
"sha256": "a" * 64,
"storage_key": f"library/verein-7/image/{'a' * 64}.jpg",
"storage_backend": "local",
"mime_type": "image/jpeg",
"byte_size": 1024,
"created_at": None,
"tags": [],
}
_ASSET_WITH_COPYRIGHT: dict = {**_PRIVATE_ASSET, "copyright_notice": "Verein 2026"}
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(get_tenant_context, None)
def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]:
mock_cur = MagicMock()
mock_cur.fetchone.return_value = asset
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm, mock_cur
_PERMISSION_PATCHES = [
("routers.media_assets.assert_can_edit_media_asset_metadata", {}),
("routers.media_assets.assert_valid_governance_visibility", {}),
("routers.media_assets._media_assets_tags_column_present", {"return_value": False}),
("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}),
("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}),
# P-06: bestehende Tests testen Copyright, nicht Rechteerklaerung "ok" mocken
("routers.media_assets.check_rights_coverage", {"return_value": "ok"}),
]
def _enter_permission_patches(stack: ExitStack) -> None:
for target, kwargs in _PERMISSION_PATCHES:
stack.enter_context(patch(target, **kwargs))
# ── Single PATCH ─────────────────────────────────────────────────────────────
def test_patch_promote_to_club_without_copyright_returns_400(client: TestClient) -> None:
"""Promotion private -> club ohne copyright_notice muss 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
detail = r.json()["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_patch_promote_to_official_without_copyright_returns_400(client: TestClient) -> None:
"""Promotion private -> official ohne copyright_notice muss 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "official"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
detail = r.json()["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None:
"""Promotion private -> club MIT copyright_notice im Body muss 200 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
updated_asset = {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"}
mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, updated_asset]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["id"] == 42
assert body["visibility"] == "club"
assert body["copyright_notice"] == "Verein 2026"
def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None:
"""Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright muss 200 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
promoted_asset = {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}
mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT)
mock_cur.fetchone.side_effect = [_ASSET_WITH_COPYRIGHT, promoted_asset]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["id"] == 42
assert body["visibility"] == "club"
assert body["copyright_notice"] == "Verein 2026"
def test_patch_filename_only_no_copyright_check(client: TestClient) -> None:
"""Kein Visibility-Wechsel -> keine Copyright-Prufung."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.side_effect = [
_PRIVATE_ASSET,
{**_PRIVATE_ASSET, "original_filename": "neu.jpg"},
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"original_filename": "neu.jpg"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
# ── Bulk PATCH ────────────────────────────────────────────────────────────────
def test_bulk_patch_promote_to_club_without_copyright_in_failed(client: TestClient) -> None:
"""Bulk-Promotion ohne copyright_notice -> Asset in failed-Liste."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cur = MagicMock()
mock_cur.fetchone.return_value = _PRIVATE_ASSET
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.post(
"/api/media-assets/bulk-patch",
json={"media_asset_ids": [42], "visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["updated_count"] == 0
assert body["failed_count"] == 1
detail = body["failed"][0]["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_bulk_patch_promote_to_club_with_copyright_in_updated(client: TestClient) -> None:
"""Bulk-Promotion MIT copyright_notice -> Asset in updated-Liste."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cur = MagicMock()
mock_cur.fetchone.return_value = _PRIVATE_ASSET
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.post(
"/api/media-assets/bulk-patch",
json={
"media_asset_ids": [42],
"visibility": "club",
"club_id": 7,
"copyright_notice": "Verein 2026",
},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert 42 in body["updated"]
assert body["updated_count"] == 1