"""Stack icons: what the server accepts, stores and hands back. The interesting part is not "does a column round-trip" — it is the three rules that keep the feature from becoming a liability: * an icon value can only ever be the automatic one or a built-in name; a ``custom:`` value is minted by the server, so a stack can never be pointed at another stack's uploaded file, * an upload is classified by its bytes, not by what the client claims it is, and * files follow the stack: replaced, cloned and deleted along with it, so the data directory does not fill up with icons of stacks that are long gone. """ from __future__ import annotations import os import pytest PNG = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDAT\x78\x9cc\x00" b"\x01\x00\x00\x05\x00\x01\x0d\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) SVG = b'' @pytest.fixture def svc(db): from services import icon_service return icon_service @pytest.fixture def stack(db): """A bare stack row to hang icons off, cleaned up afterwards.""" from sqlmodel import Session from database import engine from models.stack import Stack from services import icon_service stack_id = "icon-test-stack" with Session(engine) as session: if (existing := session.get(Stack, stack_id)) is not None: session.delete(existing) session.commit() session.add(Stack(id=stack_id, name="Icon Test Stack")) session.commit() yield stack_id icon_service.remove(stack_id) with Session(engine) as session: if (row := session.get(Stack, stack_id)) is not None: session.delete(row) session.commit() # --------------------------------------------------------------------------- # # The stored value # --------------------------------------------------------------------------- # def test_a_builtin_choice_is_stored_as_given(svc): assert svc.normalize_choice("lucide:database") == "lucide:database" def test_an_empty_choice_means_automatic(svc): assert svc.normalize_choice("") is None assert svc.normalize_choice(" ") is None @pytest.mark.parametrize( "value", [ # A client-supplied custom value would name a file: that is how one # stack would end up serving another's icon. "custom:png:1", "custom:../other:1", "lucide:../../etc/passwd", "lucide:Database", # the catalog is lower-kebab; anything else is a typo "https://example.com/logo.png", "", ], ) def test_anything_else_is_refused(svc, value): with pytest.raises(svc.IconError): svc.normalize_choice(value) # --------------------------------------------------------------------------- # # Uploads # --------------------------------------------------------------------------- # def test_an_upload_is_typed_by_its_bytes(svc, stack): assert svc.store_upload(stack, PNG).startswith("custom:png:") assert svc.store_upload(stack, SVG).startswith("custom:svg:") def test_a_file_that_is_not_an_image_is_refused(svc, stack): with pytest.raises(svc.IconError): svc.store_upload(stack, b"#!/bin/sh\nrm -rf /\n") def test_an_oversized_image_is_refused(svc, stack): payload = PNG + b"\x00" * svc.MAX_ICON_BYTES with pytest.raises(svc.IconError): svc.store_upload(stack, payload) def test_reuploading_in_another_format_leaves_no_orphan(svc, stack): svc.store_upload(stack, PNG) assert os.path.isfile(svc.custom_path(stack, "png")) value = svc.store_upload(stack, SVG) assert not os.path.exists(svc.custom_path(stack, "png")) assert svc.file_for(stack, value) == svc.custom_path(stack, "svg") def test_the_value_changes_on_every_upload(svc, stack, monkeypatch): """Otherwise a browser keeps showing the image it already cached.""" monkeypatch.setattr("services.icon_service.time.time", lambda: 1000) first = svc.store_upload(stack, PNG) monkeypatch.setattr("services.icon_service.time.time", lambda: 2000) assert svc.store_upload(stack, PNG) != first def test_removing_clears_the_file(svc, stack): value = svc.store_upload(stack, PNG) svc.remove(stack) assert svc.file_for(stack, value) is None def test_a_clone_gets_its_own_copy(svc, stack): value = svc.store_upload(stack, PNG) copied = svc.copy(stack, "icon-test-clone", value) try: assert copied is not None and copied.startswith("custom:png:") assert os.path.isfile(svc.custom_path("icon-test-clone", "png")) # Deleting the source must not take the clone's icon with it. svc.remove(stack) assert svc.file_for("icon-test-clone", copied) finally: svc.remove("icon-test-clone") def test_cloning_a_builtin_choice_carries_the_name_over(svc): assert svc.copy("a", "b", "lucide:database") == "lucide:database" assert svc.copy("a", "b", None) is None # --------------------------------------------------------------------------- # # Through the API # --------------------------------------------------------------------------- # def test_the_list_row_carries_the_icon(as_admin, stack, svc): from sqlmodel import Session from database import engine from models.stack import Stack with Session(engine) as session: row = session.get(Stack, stack) row.icon = "lucide:database" session.add(row) session.commit() body = as_admin.get(f"/api/stacks/{stack}").json() assert body["icon"] == "lucide:database" def test_upload_download_and_reset_round_trip(as_admin, stack): uploaded = as_admin.post( f"/api/stacks/{stack}/icon", files={"file": ("logo.png", PNG, "image/png")}, ) assert uploaded.status_code == 200, uploaded.text assert uploaded.json()["icon"].startswith("custom:png:") served = as_admin.get(f"/api/stacks/{stack}/icon") assert served.status_code == 200 assert served.content == PNG assert served.headers["content-type"] == "image/png" # An SVG icon must never be openable as a document in the API's own origin. assert served.headers["content-disposition"].startswith("attachment") assert as_admin.delete(f"/api/stacks/{stack}/icon").json()["icon"] is None assert as_admin.get(f"/api/stacks/{stack}/icon").status_code == 404 def test_a_lying_content_type_does_not_get_through(as_admin, stack): response = as_admin.post( f"/api/stacks/{stack}/icon", files={"file": ("logo.png", b"not an image at all", "image/png")}, ) assert response.status_code == 400 def test_switching_to_a_builtin_icon_drops_the_uploaded_file(as_admin, stack, svc): as_admin.post( f"/api/stacks/{stack}/icon", files={"file": ("logo.png", PNG, "image/png")} ) as_admin.put(f"/api/stacks/{stack}", json={"icon": "lucide:database"}) assert not os.path.exists(svc.custom_path(stack, "png")) def test_an_invalid_icon_on_update_is_a_400(as_admin, stack): response = as_admin.put(f"/api/stacks/{stack}", json={"icon": "custom:png:1"}) assert response.status_code == 400 def test_the_read_only_role_cannot_change_an_icon(as_user, stack): assert ( as_user.post( f"/api/stacks/{stack}/icon", files={"file": ("l.png", PNG, "image/png")} ).status_code == 403 ) assert as_user.delete(f"/api/stacks/{stack}/icon").status_code == 403