"""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