From e0212cfa2ca4e7fa177c83d9f9c042321a402894 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 30 Aug 2026 14:45:24 +0200 Subject: [PATCH] =?UTF-8?q?Tests=20von=2055=20auf=2012=20Sekunden,=20Abh?= =?UTF-8?q?=C3=A4ngigkeiten=20nur=20noch=20einmal=20installieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Testlauf war zu langsam. Gemessen statt geraten: 0,68 s Vorbereitung pro Test, bei 81 Tests praktisch die ganze Laufzeit. Ursache war bcrypt. Pro Test wird ein Hash gebildet und geprüft, und das ist absichtlich langsam - genau das soll es im Betrieb sein. Der Aufwand ist jetzt über BCRYPT_ROUNDS einstellbar (Standard bleibt 12) und in den Tests auf 4 gesetzt: dort geht es um die Ablauflogik, nicht um die Stärke des Hashes. Der Dekompressionsbomben-Test erzeugte ein Bild mit 400 Megapixeln, allein dafür 4,8 s. Jetzt wird stattdessen die Grenze heruntergesetzt und ein kleines Bild verwendet - dieselbe Codestelle, ohne die Wartezeit. Ausserdem installierten Test- und Anwendungs-Image dieselben Abhängigkeiten zweimal. Dockerfile.test setzt nun auf dem gebauten Anwendungs-Image auf, der Testschritt kommt entsprechend danach. Dabei zwei Fallen, die beide auffielen, weil die Tests plötzlich wieder langsam waren: - Das Basis-Image bringt seinen eigenen Stand von app/ mit. Ohne erneutes Kopieren prüfen die Tests den Code des Basis-Images - ist es veraltet, läuft alles gegen alten Code und meldet Erfolg. - Der ENTRYPOINT des Anwendungs-Images startet eine Datenbank-Migration. Für Tests weder nötig noch erwünscht, darum geleert. Ganze Kette lokal durchgespielt: bauen, testen, Image prüfen - 22 s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o --- .gitea/workflows/docker-image.yml | 22 ++++++++++++---------- Dockerfile.test | 25 +++++++++++++++++++------ app/config.py | 6 ++++++ app/security.py | 3 ++- tests/conftest.py | 4 ++++ tests/test_bilder.py | 20 +++++++++++++------- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/.gitea/workflows/docker-image.yml b/.gitea/workflows/docker-image.yml index 974db48..95a56da 100644 --- a/.gitea/workflows/docker-image.yml +++ b/.gitea/workflows/docker-image.yml @@ -25,16 +25,6 @@ jobs: - name: Quellcode auschecken uses: actions/checkout@v4 - - name: Tests - run: | - set -eu - # Über den Build-Kontext statt per Bind-Mount: läuft der Runner - # selbst in einem Container, zeigt $PWD auf einen Pfad, den der - # Docker-Daemon des Hosts nicht kennt - der Mount wäre leer und - # die Tests liefen ohne Fehlermeldung ins Leere. - docker build -f Dockerfile.test -t kleiderboerse-tests . - docker run --rm kleiderboerse-tests - - name: Version bestimmen id: meta env: @@ -79,6 +69,18 @@ jobs: --label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \ . + # Nach dem Bauen: das Test-Image setzt auf dem eben gebauten auf und + # spart sich damit ein zweites Mal Abhängigkeiten installieren. Den + # Quellcode kopiert es trotzdem frisch hinein, sonst prüfte es den + # Stand des Basis-Images. + - name: Tests + run: | + set -eu + docker build -f Dockerfile.test \ + --build-arg "BASIS=$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" \ + -t kleiderboerse-tests . + docker run --rm kleiderboerse-tests + # Prüft das fertige Image, bevor irgendetwas veröffentlicht wird. # Geprüft wird im Container selbst (kein Port-Mapping nötig, damit der # Test unabhängig vom Netzwerk-Setup des Runners funktioniert). diff --git a/Dockerfile.test b/Dockerfile.test index 447fc47..fb09d4f 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,13 +1,26 @@ -# Nur zum Testen - baut die Testumgebung samt Quellcode ins Image. +# Testumgebung - setzt auf dem fertigen Anwendungs-Image auf. +# +# Vorteil: die Abhängigkeiten sind dort schon installiert und werden nicht +# ein zweites Mal geholt. Die Tests laufen ausserdem gegen genau die +# Fassungen, die auch ausgeliefert werden. +# +# Der Quellcode wird trotzdem NEU hineinkopiert. Ohne das würden die Tests +# gegen den Stand des Basis-Images laufen - ist es veraltet, prüfen sie +# stillschweigend alten Code und melden Erfolg. +# +# ENTRYPOINT wird geleert: der des Anwendungs-Images führt eine +# Datenbank-Migration aus, die für die Tests weder nötig noch erwünscht ist. # # Bewusst ohne Bind-Mount: läuft der CI-Runner selbst in einem Container, # zeigt $PWD auf einen Pfad, den der Docker-Daemon des Hosts nicht kennt. -# Der Mount wäre dann leer und die Tests liefen ins Leere - ohne -# Fehlermeldung. Über den Build-Kontext kommt der Code zuverlässig hinein. -FROM python:3.11-slim +# Der Mount wäre leer und die Tests liefen ohne Fehlermeldung ins Leere. +ARG BASIS=kleiderboerse-app +FROM ${BASIS} -WORKDIR /app -COPY requirements.txt requirements-dev.txt ./ +USER root +ENTRYPOINT [] + +COPY requirements-dev.txt ./ RUN pip install --no-cache-dir -r requirements-dev.txt COPY alembic.ini ./ diff --git a/app/config.py b/app/config.py index 32378d3..4b621d0 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,12 @@ class Einstellungen(BaseSettings): limit_reservieren: str = Field(default="5/hour", validation_alias="RATE_LIMIT_RESERVE") limit_anmelden: str = Field(default="10/hour", validation_alias="RATE_LIMIT_LOGIN") + # Rechenaufwand für das Passwort-Hashing. 12 ist der sinnvolle + # Standard; bcrypt ist absichtlich langsam, damit sich Passwörter + # nicht durchprobieren lassen. In Tests wird der Wert gesenkt - + # sonst kostet jeder Test rund eine halbe Sekunde nur dafür. + bcrypt_runden: int = Field(default=12, validation_alias="BCRYPT_ROUNDS") + # Nach dieser Zeit ohne Aktivität wird abgemeldet. sitzung_max_ruhe_sekunden: int = Field(default=7200, validation_alias="SESSION_IDLE_SECONDS") diff --git a/app/security.py b/app/security.py index c49bf10..2239036 100644 --- a/app/security.py +++ b/app/security.py @@ -25,7 +25,8 @@ limiter = Limiter(key_func=get_remote_address) # ------------------------------------------------------------- Passwort --- def passwort_hash(klartext: str) -> str: - return bcrypt.hashpw(klartext.encode(), bcrypt.gensalt()).decode() + runden = einstellungen().bcrypt_runden + return bcrypt.hashpw(klartext.encode(), bcrypt.gensalt(runden)).decode() def passwort_stimmt(klartext: str, hash_wert: str) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index eaf615b..3022338 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,10 @@ TEST_PASSWORT = "test-passwort-123" os.environ.setdefault("COOKIE_HTTPS_ONLY", "false") os.environ.setdefault("ADMIN_PASSWORD", TEST_PASSWORT) os.environ.setdefault("SECRET_KEY", "nur-fuer-tests") +# bcrypt ist absichtlich langsam. Bei rund 160 Hash-Vorgängen über +# alle Tests kostete der Standardaufwand knapp eine Minute; hier +# geht es um die Ablauflogik, nicht um die Stärke des Hashes. +os.environ.setdefault("BCRYPT_ROUNDS", "4") import pytest # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 diff --git a/tests/test_bilder.py b/tests/test_bilder.py index 451ed87..1b2bcca 100644 --- a/tests/test_bilder.py +++ b/tests/test_bilder.py @@ -127,21 +127,27 @@ def test_zu_grosse_datei_wird_abgewiesen(client, item_id, monkeypatch): einstellungen.cache_clear() -def test_dekompressionsbombe_wird_abgewiesen(client, item_id): - """Ein paar hundert Kilobyte, die beim Entpacken den Speicher füllen. +def test_dekompressionsbombe_wird_abgewiesen(client, item_id, monkeypatch): + """Ein paar Kilobyte, die beim Entpacken den Speicher füllen. Pillow allein würde hier nur warnen - der Fehler kommt erst bei der doppelten Pixelzahl. Darum die ausdrückliche Prüfung in images.py. + + Statt wirklich 400 Megapixel zu erzeugen (was den Test um Sekunden + verlängerte) wird die Grenze heruntergesetzt: geprüft wird dieselbe + Codestelle, nur eben mit einem kleinen Bild. """ + from app import images + + monkeypatch.setattr(images, "MAX_PIXEL", 100_000) + puffer = io.BytesIO() - # 20000 x 20000 = 400 Megapixel, als einfarbiges PNG winzig komprimiert - Image.new("RGB", (20000, 20000), (0, 0, 0)).save(puffer, "PNG", compress_level=9) - roh = puffer.getvalue() - assert len(roh) < 2 * 1024 * 1024, "Testbild sollte klein sein" + # 600 x 600 = 360'000 Bildpunkte, also über der gesetzten Grenze + Image.new("RGB", (600, 600), (0, 0, 0)).save(puffer, "PNG", compress_level=9) antwort = client.post( f"/api/v1/items/{item_id}/images", - files={"dateien": ("bombe.png", roh, "image/png")}, + files={"dateien": ("bombe.png", puffer.getvalue(), "image/png")}, ) assert antwort.status_code == 422 assert "Bildpunkte" in antwort.json()["detail"]