Files
Kochbuch/tests/test_privacy.py
T
Kyle DammannandClaude Opus 5.5 6f7e4f92ca Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps,
  lowercase tags, author notes, source URL and private/public visibility
- Central RecipeQuerySet.visible_to() used by every read path; edit only
  by owner, delete by owner or admin (visible recipes only)
- Text-based editing: ingredient/step parser with roundtrip and scaling
- Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF,
  thumbnails, cover image, served only through permission-checked view
- Search across title, description, ingredients, tags; ingredient and tag
  filters; "mine" and favourites views
- Admin can delete all recipes of a deactivated user
- Acceptance tests: private recipes unreachable for others and admins
  in list, search, tags, favourites, detail, edit, delete and image URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:13 +01:00

146 lines
5.0 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.
"""Acceptance: a private recipe is reachable by its owner only – in no view and under no URL,
including image URLs. Other users and admins get 404 (indistinguishable from "does not exist")."""
import pytest
from django.test import Client
from django.urls import reverse
from recipes.models import Favorite, Recipe, RecipeImage
from .recipe_helpers import image_upload, make_recipe
pytestmark = pytest.mark.django_db
SECRET_TITLE = "Geheimrezept Omas Gulasch"
SECRET_INGREDIENT = "Zauberpaprika"
SECRET_TAG = "geheimtag"
@pytest.fixture
def private_recipe(user):
recipe = make_recipe(user, title=SECRET_TITLE, visibility=Recipe.Visibility.PRIVATE,
ingredients=f"500 g {SECRET_INGREDIENT}", tags=(SECRET_TAG,))
owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
assert recipe.images.count() == 1
return recipe
@pytest.fixture
def image(private_recipe):
return RecipeImage.objects.get(recipe=private_recipe)
@pytest.fixture(params=["other", "admin"])
def outsider(request, other_user, admin_user):
c = Client()
c.force_login(other_user if request.param == "other" else admin_user)
return c
def owner_client(user):
c = Client()
c.force_login(user)
return c
def get_urls(recipe, image):
return [
reverse("recipes:detail", args=[recipe.pk]),
reverse("recipes:detail", args=[recipe.pk]) + "?portionen=8",
reverse("recipes:edit", args=[recipe.pk]),
reverse("recipes:delete", args=[recipe.pk]),
reverse("recipes:photo", args=[image.uuid, "gross"]),
reverse("recipes:photo", args=[image.uuid, "klein"]),
*extra_get_urls(recipe, image),
]
def post_urls(recipe, image):
return [
reverse("recipes:edit", args=[recipe.pk]),
reverse("recipes:delete", args=[recipe.pk]),
reverse("recipes:favorite", args=[recipe.pk]),
reverse("recipes:photo_upload", args=[recipe.pk]),
reverse("recipes:photo_cover", args=[recipe.pk, image.pk]),
reverse("recipes:photo_delete", args=[recipe.pk, image.pk]),
*extra_post_urls(recipe, image),
]
def extra_get_urls(recipe, image):
"""Hook for later features (comments, cooking mode, …)."""
return []
def extra_post_urls(recipe, image):
return []
def test_owner_sees_everything(user, private_recipe, image):
c = owner_client(user)
for url in get_urls(private_recipe, image):
assert c.get(url).status_code == 200, url
assert SECRET_TITLE in c.get(reverse("recipes:list")).content.decode()
def test_outsider_gets_404_on_every_get_url(outsider, private_recipe, image):
for url in get_urls(private_recipe, image):
assert outsider.get(url).status_code == 404, url
def test_outsider_gets_404_on_every_post_url(outsider, private_recipe, image):
for url in post_urls(private_recipe, image):
response = outsider.post(url, {"title": "gehackt", "servings": 1, "visibility": "public", "text": "x"})
assert response.status_code == 404, url
private_recipe.refresh_from_db()
assert private_recipe.title == SECRET_TITLE and not private_recipe.is_public
assert RecipeImage.objects.filter(pk=image.pk).exists()
assert not Favorite.objects.exists()
@pytest.mark.parametrize(
"query",
[
{},
{"q": "Geheimrezept"},
{"q": SECRET_INGREDIENT},
{"q": SECRET_TAG},
{"zutat": SECRET_INGREDIENT},
{"tag": SECRET_TAG},
{"ansicht": "favoriten"},
],
)
def test_private_recipe_absent_from_list_and_search(outsider, private_recipe, image, query):
html = outsider.get(reverse("recipes:list"), query).content.decode()
assert SECRET_TITLE not in html
assert str(image.uuid) not in html
assert reverse("recipes:detail", args=[private_recipe.pk]) not in html
def test_private_tag_not_listed(outsider, private_recipe):
assert f"#{SECRET_TAG}" not in outsider.get(reverse("recipes:list")).content.decode()
def test_existing_favorite_disappears_when_recipe_turns_private(user, other_user):
recipe = make_recipe(user, title="Bald privat")
Favorite.objects.create(user=other_user, recipe=recipe)
recipe.visibility = Recipe.Visibility.PRIVATE
recipe.save()
c = owner_client(other_user)
assert "Bald privat" not in c.get(reverse("recipes:list"), {"ansicht": "favoriten"}).content.decode()
assert c.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 404
def test_anonymous_gets_redirected_everywhere(private_recipe, image):
c = Client()
for url in get_urls(private_recipe, image):
response = c.get(url)
assert response.status_code == 302 and "/anmelden/" in response["Location"], url
def test_visible_to_queryset_rule(user, other_user, admin_user, private_recipe):
public = make_recipe(user, title="Öffentlich")
assert set(Recipe.objects.visible_to(user)) == {private_recipe, public}
assert set(Recipe.objects.visible_to(other_user)) == {public}
assert set(Recipe.objects.visible_to(admin_user)) == {public}