mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			218 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			218 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| import pytest
 | |
| from bs4 import BeautifulSoup
 | |
| 
 | |
| from mealie.routes import spa
 | |
| from mealie.schema.recipe.recipe import Recipe, RecipeSettings
 | |
| from mealie.schema.recipe.recipe_notes import RecipeNote
 | |
| from mealie.schema.recipe.recipe_share_token import RecipeShareTokenSave
 | |
| from tests import data as test_data
 | |
| from tests.utils.factories import random_string
 | |
| from tests.utils.fixture_schemas import TestUser
 | |
| 
 | |
| 
 | |
| @pytest.fixture(autouse=True)
 | |
| def set_spa_contents():
 | |
|     """Inject a simple HTML string into the SPA module to enable metadata injection"""
 | |
| 
 | |
|     spa.__contents = "<!DOCTYPE html><html><head></head><body></body></html>"
 | |
| 
 | |
| 
 | |
| def set_group_is_private(unique_user: TestUser, *, is_private: bool):
 | |
|     group = unique_user.repos.groups.get_by_slug_or_id(unique_user.group_id)
 | |
|     assert group and group.preferences
 | |
|     group.preferences.private_group = is_private
 | |
|     unique_user.repos.group_preferences.update(group.id, group.preferences)
 | |
| 
 | |
| 
 | |
| def set_recipe_is_public(unique_user: TestUser, recipe: Recipe, *, is_public: bool):
 | |
|     assert recipe.settings
 | |
|     recipe.settings.public = is_public
 | |
|     unique_user.repos.recipes.update(recipe.slug, recipe)
 | |
| 
 | |
| 
 | |
| def create_recipe(user: TestUser) -> Recipe:
 | |
|     recipe = user.repos.recipes.create(
 | |
|         Recipe(
 | |
|             user_id=user.user_id,
 | |
|             group_id=user.group_id,
 | |
|             name=random_string(),
 | |
|         )
 | |
|     )
 | |
|     set_group_is_private(user, is_private=False)
 | |
|     set_recipe_is_public(user, recipe, is_public=True)
 | |
| 
 | |
|     return recipe
 | |
| 
 | |
| 
 | |
| def test_spa_metadata_injection():
 | |
|     fp = test_data.html_mealie_recipe
 | |
|     with open(fp) as f:
 | |
|         soup = BeautifulSoup(f, "lxml")
 | |
|         assert soup.html and soup.html.head
 | |
| 
 | |
|         tags = soup.find_all("meta")
 | |
|         assert tags
 | |
| 
 | |
|         title_tag = None
 | |
|         for tag in tags:
 | |
|             if tag.get("data-hid") == "og:title":
 | |
|                 title_tag = tag
 | |
|                 break
 | |
| 
 | |
|         assert title_tag and title_tag["content"]
 | |
| 
 | |
|         new_title_tag = spa.MetaTag(hid="og:title", property_name="og:title", content=random_string())
 | |
|         new_arbitrary_tag = spa.MetaTag(hid=random_string(), property_name=random_string(), content=random_string())
 | |
|         new_html = spa.inject_meta(str(soup), [new_title_tag, new_arbitrary_tag])
 | |
| 
 | |
|     # verify changes were injected
 | |
|     soup = BeautifulSoup(new_html, "lxml")
 | |
|     assert soup.html and soup.html.head
 | |
| 
 | |
|     tags = soup.find_all("meta")
 | |
|     assert tags
 | |
| 
 | |
|     title_tag = None
 | |
|     for tag in tags:
 | |
|         if tag.get("data-hid") == "og:title":
 | |
|             title_tag = tag
 | |
|             break
 | |
| 
 | |
|     assert title_tag and title_tag["content"] == new_title_tag.content
 | |
| 
 | |
|     arbitrary_tag = None
 | |
|     for tag in tags:
 | |
|         if tag.get("data-hid") == new_arbitrary_tag.hid:
 | |
|             arbitrary_tag = tag
 | |
|             break
 | |
| 
 | |
|     assert arbitrary_tag and arbitrary_tag["content"] == new_arbitrary_tag.content
 | |
| 
 | |
| 
 | |
| def test_spa_recipe_json_injection():
 | |
|     recipe_name = random_string()
 | |
|     schema = {
 | |
|         "@context": "https://schema.org",
 | |
|         "@type": "Recipe",
 | |
|         "name": recipe_name,
 | |
|     }
 | |
| 
 | |
|     fp = test_data.html_mealie_recipe
 | |
|     with open(fp) as f:
 | |
|         soup = BeautifulSoup(f, "lxml")
 | |
|         assert "https://schema.org" not in str(soup)
 | |
| 
 | |
|         html = spa.inject_recipe_json(str(soup), schema)
 | |
| 
 | |
|     assert "@context" in html
 | |
|     assert "https://schema.org" in html
 | |
|     assert recipe_name in html
 | |
| 
 | |
| 
 | |
| @pytest.mark.parametrize("use_public_user", [True, False])
 | |
| @pytest.mark.asyncio
 | |
| async def test_spa_serve_recipe_with_meta(unique_user: TestUser, use_public_user: bool):
 | |
|     recipe = create_recipe(unique_user)
 | |
|     user = unique_user.repos.users.get_by_username(unique_user.username)
 | |
|     assert user
 | |
| 
 | |
|     response = await spa.serve_recipe_with_meta(
 | |
|         user.group_slug, recipe.slug, user=None if use_public_user else user, session=unique_user.repos.session
 | |
|     )
 | |
|     assert response.status_code == 200
 | |
|     assert "https://schema.org" in response.body.decode()
 | |
| 
 | |
| 
 | |
| @pytest.mark.parametrize("use_public_user", [True, False])
 | |
| @pytest.mark.asyncio
 | |
| async def test_spa_serve_recipe_with_meta_invalid_data(unique_user: TestUser, use_public_user: bool):
 | |
|     recipe = create_recipe(unique_user)
 | |
|     user = unique_user.repos.users.get_by_username(unique_user.username)
 | |
|     assert user
 | |
| 
 | |
|     response = await spa.serve_recipe_with_meta(
 | |
|         random_string(), recipe.slug, user=None if use_public_user else user, session=unique_user.repos.session
 | |
|     )
 | |
|     assert response.status_code == 404
 | |
| 
 | |
|     response = await spa.serve_recipe_with_meta(
 | |
|         user.group_slug, random_string(), user=None if use_public_user else user, session=unique_user.repos.session
 | |
|     )
 | |
|     assert response.status_code == 404
 | |
| 
 | |
|     set_recipe_is_public(unique_user, recipe, is_public=False)
 | |
|     response = await spa.serve_recipe_with_meta(
 | |
|         user.group_slug, recipe.slug, user=None if use_public_user else user, session=unique_user.repos.session
 | |
|     )
 | |
|     if use_public_user:
 | |
|         assert response.status_code == 404
 | |
|     else:
 | |
|         assert response.status_code == 200
 | |
| 
 | |
|     set_group_is_private(unique_user, is_private=True)
 | |
|     set_recipe_is_public(unique_user, recipe, is_public=True)
 | |
|     response = await spa.serve_recipe_with_meta(
 | |
|         user.group_slug, recipe.slug, user=None if use_public_user else user, session=unique_user.repos.session
 | |
|     )
 | |
|     if use_public_user:
 | |
|         assert response.status_code == 404
 | |
|     else:
 | |
|         assert response.status_code == 200
 | |
| 
 | |
| 
 | |
| @pytest.mark.parametrize("use_private_group", [True, False])
 | |
| @pytest.mark.parametrize("use_public_recipe", [True, False])
 | |
| @pytest.mark.asyncio
 | |
| async def test_spa_service_shared_recipe_with_meta(
 | |
|     unique_user: TestUser, use_private_group: bool, use_public_recipe: bool
 | |
| ):
 | |
|     group = unique_user.repos.groups.get_by_slug_or_id(unique_user.group_id)
 | |
|     assert group
 | |
|     recipe = create_recipe(unique_user)
 | |
| 
 | |
|     # visibility settings shouldn't matter for shared recipes
 | |
|     set_group_is_private(unique_user, is_private=use_private_group)
 | |
|     set_recipe_is_public(unique_user, recipe, is_public=use_public_recipe)
 | |
| 
 | |
|     token = unique_user.repos.recipe_share_tokens.create(
 | |
|         RecipeShareTokenSave(recipe_id=recipe.id, group_id=unique_user.group_id)
 | |
|     )
 | |
| 
 | |
|     response = await spa.serve_shared_recipe_with_meta(group.slug, token.id, session=unique_user.repos.session)
 | |
|     assert response.status_code == 200
 | |
|     assert "https://schema.org" in response.body.decode()
 | |
| 
 | |
| 
 | |
| @pytest.mark.asyncio
 | |
| async def test_spa_service_shared_recipe_with_meta_invalid_data(unique_user: TestUser):
 | |
|     group = unique_user.repos.groups.get_by_slug_or_id(unique_user.group_id)
 | |
|     assert group
 | |
| 
 | |
|     response = await spa.serve_shared_recipe_with_meta(group.slug, random_string(), session=unique_user.repos.session)
 | |
|     assert response.status_code == 404
 | |
| 
 | |
| 
 | |
| @pytest.mark.parametrize(
 | |
|     "malicious_content, malicious_strings",
 | |
|     [
 | |
|         ("<script>alert('XSS');</script>", ["<script>", "alert('XSS')"]),
 | |
|         ("<img src=x onerror=alert('XSS')>", ["<img", "onerror=alert('XSS')"]),
 | |
|         ("<div onmouseover=alert('XSS')>Hover me</div>", ["<div", "onmouseover=alert('XSS')"]),
 | |
|         ("<a href='javascript:alert(\"XSS\")'>Click me</a>", ["<a", 'javascript:alert("XSS")']),
 | |
|     ],
 | |
| )
 | |
| def test_spa_escapes_malicious_recipe_data(unique_user: TestUser, malicious_content: str, malicious_strings: list[str]):
 | |
|     recipe = Recipe(
 | |
|         user_id=unique_user.user_id,
 | |
|         group_id=unique_user.group_id,
 | |
|         name=malicious_content,
 | |
|         description=malicious_content,
 | |
|         image=malicious_content,
 | |
|         notes=[RecipeNote(title=malicious_content, text=malicious_content)],
 | |
|         settings=RecipeSettings(),
 | |
|     )
 | |
| 
 | |
|     response = spa.content_with_meta(unique_user.group_id, recipe)
 | |
|     for string in malicious_strings:
 | |
|         assert string not in response
 |