import io import json import socket import pytest from django.urls import reverse from PIL import Image from recipes import ai, fetch, import_views, instagram, schema_org from recipes import views as recipe_views from recipes.fetch import FetchError, FetchResult from recipes.models import Recipe from .recipe_helpers import form_data from .test_ai import ai_answer # --- SSRF protection ------------------------------------------------------------------------ def fake_dns(monkeypatch, ip): monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 443))]) @pytest.mark.parametrize("ip", ["127.0.0.1", "10.1.2.3", "192.168.178.1", "172.17.0.1", "169.254.169.254", "100.64.0.1", "0.0.0.0"]) def test_internal_addresses_blocked(monkeypatch, ip): fake_dns(monkeypatch, ip) with pytest.raises(FetchError, match="nicht erlaubt"): fetch.fetch("https://harmless-looking.example/recipe") def test_ipv6_loopback_and_mapped_blocked(monkeypatch): for ip in ["::1", "::ffff:127.0.0.1", "fd00::1"]: monkeypatch.setattr(socket, "getaddrinfo", lambda *a, ip=ip, **k: [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ip, 443, 0, 0))]) with pytest.raises(FetchError): fetch.fetch("https://x.example/") @pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://x.example/", "https://user:pw@x.example/", "https://x.example:22/", "gopher://x"]) def test_bad_urls_rejected(url): with pytest.raises(FetchError): fetch.fetch(url) def test_redirect_target_is_checked_again(monkeypatch): class Response: status = 302 headers = {"Location": "http://internal.example/admin"} def release_conn(self): pass def fake_request(url, accept, timeout): if "internal" in url: raise FetchError("Diese Adresse ist nicht erlaubt (internes Netz).") return Response() monkeypatch.setattr(fetch, "_request_once", fake_request) with pytest.raises(FetchError, match="nicht erlaubt"): fetch.fetch("https://public.example/") # --- schema.org parsing ----------------------------------------------------------------------- RECIPE_LD = { "@context": "https://schema.org", "@graph": [ {"@type": "WebPage", "name": "Seite"}, { "@type": ["Recipe", "NewsArticle"], "name": "Kürbissuppe & Ingwer", "description": "
Wärmt von innen.
", "recipeYield": ["4", "4 Portionen"], "prepTime": "PT15M", "cookTime": "PT1H5M", "image": [{"@type": "ImageObject", "url": "https://cdn.example/suppe.jpg"}], "keywords": "Suppe, Herbst, vegan", "recipeCategory": "Hauptgericht", "recipeIngredient": ["1 kg Hokkaido", "2 cm Ingwer", "400 ml Kokosmilch"], "recipeInstructions": [ {"@type": "HowToSection", "name": "Vorbereitung", "itemListElement": [ {"@type": "HowToStep", "text": "Kürbis würfeln."}, ]}, {"@type": "HowToStep", "text": "Alles pürieren."}, ], }, ], } def page_with(ld, extra=""): return f"""Text
""" def test_schema_org_recipe_extracted(): initial, image, _ = schema_org.extract(page_with(RECIPE_LD)) assert initial["title"] == "Kürbissuppe & Ingwer" assert initial["description"] == "Wärmt von innen." assert initial["servings"] == 4 and initial["prep_minutes"] == 15 and initial["cook_minutes"] == 65 assert initial["ingredients_text"].splitlines() == ["1 kg Hokkaido", "2 cm Ingwer", "400 ml Kokosmilch"] assert initial["steps_text"] == "Kürbis würfeln.\n\nAlles pürieren." assert initial["tags_text"] == "hauptgericht, suppe, herbst, vegan" assert image == "https://cdn.example/suppe.jpg" def test_schema_org_string_instructions_and_yield(): ld = {"@type": "Recipe", "name": "X", "recipeYield": "12 Stück", "recipeIngredient": ["Mehl"], "recipeInstructions": "Erst das.Hallo Welt
") assert initial is None assert "Hallo Welt" in page.visible_text() and "Menü" not in page.visible_text() and "x()" not in page.visible_text() # --- Instagram --------------------------------------------------------------------------------- @pytest.mark.parametrize( "url, code", [ ("https://www.instagram.com/p/C1a2b3c4D5e/", "C1a2b3c4D5e"), ("https://instagram.com/reel/C1a2b3c4D5e?igsh=abc", "C1a2b3c4D5e"), ("https://www.instagram.com/kochprofi/p/C1a2b3c4D5e/", "C1a2b3c4D5e"), ("https://www.instagram.com/reels/DaU7f-CjE1c/", "DaU7f-CjE1c"), ("https://www.instagram.com/kochprofi/", None), ("https://evil.example/p/C1a2b3c4D5e/", None), ("https://instagram.com.evil.example/p/C1a2b3c4D5e/", None), ], ) def test_shortcode(url, code): assert instagram.shortcode(url) == code EMBED_HTML = """
200 g Linsen kochen ...
") seen = [] monkeypatch.setattr(ai, "_create_completion", lambda messages: seen.append(messages) or ai_answer()) response = user_client.post(reverse("recipes:import_link"), {"url": "https://blog.example/dal"}) assert response.context["form"].initial["title"] == "Linsen-Dal" assert "200 g Linsen" in seen[0][1]["content"] @pytest.mark.django_db def test_link_import_without_recipe_and_without_ai(monkeypatch, settings, user_client): settings.OPENAI_API_KEY = "" stub_fetch(monkeypatch, "nichts") response = user_client.post(reverse("recipes:import_link"), {"url": "https://blog.example/x"}) assert response.status_code == 400 assert "kein strukturiertes Rezept" in response.content.decode() @pytest.mark.django_db def test_link_import_reports_fetch_errors(monkeypatch, user_client): def fail(url, **kwargs): raise FetchError("Die Seite ist nicht erreichbar.") monkeypatch.setattr(import_views, "fetch", fail) response = user_client.post(reverse("recipes:import_link"), {"url": "https://down.example/"}) assert "nicht erreichbar" in response.content.decode() @pytest.mark.django_db def test_instagram_failure_offers_paste_fallback(monkeypatch, user_client): def unavailable(code): raise instagram.InstagramUnavailable("nope") monkeypatch.setattr(instagram, "fetch_caption", unavailable) url = "https://www.instagram.com/p/C1a2b3c4D5e/" response = user_client.post(reverse("recipes:import_link"), {"url": url}, follow=True) html = response.content.decode() assert "So klappt es mit Instagram" in html assert response.context["text_form"].initial["source_url"] == url @pytest.mark.django_db def test_instagram_caption_structured_by_ai(monkeypatch, settings, user_client): settings.OPENAI_API_KEY = "sk-test" monkeypatch.setattr(instagram, "fetch_caption", lambda code: ("Dal: 200 g Linsen, Kokosmilch. Kochen!", "https://cdn.example/i.jpg")) seen = [] monkeypatch.setattr(ai, "_create_completion", lambda messages: seen.append(messages) or ai_answer()) response = user_client.post(reverse("recipes:import_link"), {"url": "https://www.instagram.com/reel/C1a2b3c4D5e/"}) initial = response.context["form"].initial assert initial["title"] == "Linsen-Dal" and initial["import_image_url"] == "https://cdn.example/i.jpg" assert "Instagram" in seen[0][1]["content"] @pytest.mark.django_db def test_instagram_profile_link_rejected(user_client): response = user_client.post(reverse("recipes:import_link"), {"url": "https://www.instagram.com/kochprofi/"}) assert "einzelnen Beitrag" in response.content.decode() def jpeg_bytes(): buffer = io.BytesIO() Image.new("RGB", (800, 600), (10, 200, 10)).save(buffer, "JPEG") return buffer.getvalue() @pytest.mark.django_db def test_saving_draft_downloads_source_image(monkeypatch, user_client): monkeypatch.setattr(recipe_views.fetch, "fetch", lambda url, **kw: FetchResult(url, 200, "image/jpeg", jpeg_bytes())) user_client.post(reverse("recipes:create"), form_data(import_image_url="https://cdn.example/i.jpg", use_import_image="on")) recipe = Recipe.objects.get() assert recipe.images.count() == 1 and recipe.images.get().is_cover @pytest.mark.django_db def test_source_image_skipped_when_unticked(monkeypatch, user_client): called = [] monkeypatch.setattr(recipe_views.fetch, "fetch", lambda url, **kw: called.append(url)) user_client.post(reverse("recipes:create"), form_data(import_image_url="https://cdn.example/i.jpg")) assert not called and Recipe.objects.get().images.count() == 0 @pytest.mark.django_db def test_source_image_to_internal_address_is_refused(monkeypatch, user_client): fake_dns(monkeypatch, "10.0.0.5") response = user_client.post(reverse("recipes:create"), form_data(import_image_url="https://nas.example/secret.jpg", use_import_image="on"), follow=True) assert "nicht erlaubt" in response.content.decode() assert Recipe.objects.get().images.count() == 0 @pytest.mark.django_db def test_source_image_must_be_an_image(monkeypatch, user_client): monkeypatch.setattr(recipe_views.fetch, "fetch", lambda url, **kw: FetchResult(url, 200, "text/html", b"")) user_client.post(reverse("recipes:create"), form_data(import_image_url="https://x.example/", use_import_image="on")) assert Recipe.objects.get().images.count() == 0 # --- PWA / share target ---------------------------------------------------------------------------- @pytest.mark.django_db @pytest.mark.parametrize( "params, expected", [ ({"url": "https://www.instagram.com/p/C1a2b3c4D5e/"}, "https%3A%2F%2Fwww.instagram.com%2Fp%2FC1a2b3c4D5e%2F"), ({"text": "Schau mal https://www.instagram.com/reel/XYZ12345/ lecker!"}, "reel%2FXYZ12345%2F"), ], ) def test_share_target_redirects_to_import(user_client, params, expected): response = user_client.get(reverse("recipes:share"), params) assert response.status_code == 302 and expected in response["Location"] @pytest.mark.django_db def test_share_target_with_plain_text(user_client): response = user_client.get(reverse("recipes:share"), {"text": "200 g Mehl und 2 Eier verrühren"}) assert response.context["text_form"].initial["text"].startswith("200 g Mehl") @pytest.mark.django_db def test_import_page_prefills_url(user_client): response = user_client.get(reverse("recipes:import"), {"url": "https://x.example/r"}) assert response.context["link_form"].initial["url"] == "https://x.example/r" @pytest.mark.django_db def test_manifest_and_service_worker(client): manifest = client.get("/manifest.webmanifest") assert manifest.status_code == 200 and manifest["Content-Type"] == "application/manifest+json" data = manifest.json() assert data["share_target"]["action"] == reverse("recipes:share") sw = client.get("/sw.js") assert sw.status_code == 200 and "javascript" in sw["Content-Type"] assert "fetch" not in sw.content.decode() # no caching of private pages