mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-11-03 18:53:17 -05: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
 |