Tests von 55 auf 12 Sekunden, Abhängigkeiten nur noch einmal installieren
Docker-Image bauen / build (push) Successful in 1m7s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
2026-08-30 14:45:24 +02:00
co-authored by Claude Opus 5
parent 2a55dcf1ce
commit e0212cfa2c
6 changed files with 56 additions and 24 deletions
+12 -10
View File
@@ -25,16 +25,6 @@ jobs:
- name: Quellcode auschecken - name: Quellcode auschecken
uses: actions/checkout@v4 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 - name: Version bestimmen
id: meta id: meta
env: env:
@@ -79,6 +69,18 @@ jobs:
--label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \ --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. # Prüft das fertige Image, bevor irgendetwas veröffentlicht wird.
# Geprüft wird im Container selbst (kein Port-Mapping nötig, damit der # Geprüft wird im Container selbst (kein Port-Mapping nötig, damit der
# Test unabhängig vom Netzwerk-Setup des Runners funktioniert). # Test unabhängig vom Netzwerk-Setup des Runners funktioniert).
+19 -6
View File
@@ -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, # 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. # 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 # Der Mount wäre leer und die Tests liefen ohne Fehlermeldung ins Leere.
# Fehlermeldung. Über den Build-Kontext kommt der Code zuverlässig hinein. ARG BASIS=kleiderboerse-app
FROM python:3.11-slim FROM ${BASIS}
WORKDIR /app USER root
COPY requirements.txt requirements-dev.txt ./ ENTRYPOINT []
COPY requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt RUN pip install --no-cache-dir -r requirements-dev.txt
COPY alembic.ini ./ COPY alembic.ini ./
+6
View File
@@ -50,6 +50,12 @@ class Einstellungen(BaseSettings):
limit_reservieren: str = Field(default="5/hour", validation_alias="RATE_LIMIT_RESERVE") limit_reservieren: str = Field(default="5/hour", validation_alias="RATE_LIMIT_RESERVE")
limit_anmelden: str = Field(default="10/hour", validation_alias="RATE_LIMIT_LOGIN") 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. # Nach dieser Zeit ohne Aktivität wird abgemeldet.
sitzung_max_ruhe_sekunden: int = Field(default=7200, sitzung_max_ruhe_sekunden: int = Field(default=7200,
validation_alias="SESSION_IDLE_SECONDS") validation_alias="SESSION_IDLE_SECONDS")
+2 -1
View File
@@ -25,7 +25,8 @@ limiter = Limiter(key_func=get_remote_address)
# ------------------------------------------------------------- Passwort --- # ------------------------------------------------------------- Passwort ---
def passwort_hash(klartext: str) -> str: 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: def passwort_stimmt(klartext: str, hash_wert: str) -> bool:
+4
View File
@@ -17,6 +17,10 @@ TEST_PASSWORT = "test-passwort-123"
os.environ.setdefault("COOKIE_HTTPS_ONLY", "false") os.environ.setdefault("COOKIE_HTTPS_ONLY", "false")
os.environ.setdefault("ADMIN_PASSWORD", TEST_PASSWORT) os.environ.setdefault("ADMIN_PASSWORD", TEST_PASSWORT)
os.environ.setdefault("SECRET_KEY", "nur-fuer-tests") 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 import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
+13 -7
View File
@@ -127,21 +127,27 @@ def test_zu_grosse_datei_wird_abgewiesen(client, item_id, monkeypatch):
einstellungen.cache_clear() einstellungen.cache_clear()
def test_dekompressionsbombe_wird_abgewiesen(client, item_id): def test_dekompressionsbombe_wird_abgewiesen(client, item_id, monkeypatch):
"""Ein paar hundert Kilobyte, die beim Entpacken den Speicher füllen. """Ein paar Kilobyte, die beim Entpacken den Speicher füllen.
Pillow allein würde hier nur warnen - der Fehler kommt erst bei der Pillow allein würde hier nur warnen - der Fehler kommt erst bei der
doppelten Pixelzahl. Darum die ausdrückliche Prüfung in images.py. 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() puffer = io.BytesIO()
# 20000 x 20000 = 400 Megapixel, als einfarbiges PNG winzig komprimiert # 600 x 600 = 360'000 Bildpunkte, also über der gesetzten Grenze
Image.new("RGB", (20000, 20000), (0, 0, 0)).save(puffer, "PNG", compress_level=9) Image.new("RGB", (600, 600), (0, 0, 0)).save(puffer, "PNG", compress_level=9)
roh = puffer.getvalue()
assert len(roh) < 2 * 1024 * 1024, "Testbild sollte klein sein"
antwort = client.post( antwort = client.post(
f"/api/v1/items/{item_id}/images", 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 antwort.status_code == 422
assert "Bildpunkte" in antwort.json()["detail"] assert "Bildpunkte" in antwort.json()["detail"]