smolweb/tests/test_site.py
2026-09-26 13:34:12 +03:00

141 lines
5.2 KiB
Python

"""Path resolution: traversal safety, extension rules, and live reload."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from smolweb.site import Document, NotFound, RawFile, Redirect, Site, WmlCard
class TestPathTraversal:
@pytest.mark.parametrize(
"path",
[
"/../../etc/passwd",
"/../../../../../../etc/passwd",
"/foo/../../etc/passwd",
"/%2e%2e/etc/passwd", # percent-decoded to ".." before normalizing, then clamped
],
)
def test_traversal_attempts_are_refused(self, site: Site, path: str):
with pytest.raises(NotFound):
site.resolve(path)
def test_symlink_escaping_root_is_refused(self, tmp_path: Path):
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.md").write_text("# Secret\n", encoding="utf-8")
root = tmp_path / "root"
root.mkdir()
(root / "escape.md").symlink_to(outside / "secret.md")
site = Site(root)
with pytest.raises(NotFound):
site.resolve("/escape")
def test_dotfile_paths_are_refused(self, site: Site):
with pytest.raises(NotFound):
site.resolve("/.secret")
class TestResolutionRules:
def test_root_serves_index(self, site: Site):
doc = site.resolve("/")
assert isinstance(doc, Document)
assert b"# Home" in doc.gemtext
def test_extensionless_path_serves_md_file(self, site: Site):
doc = site.resolve("/about")
assert isinstance(doc, Document)
assert b"# About" in doc.gemtext
def test_directory_serves_its_index(self, site: Site):
doc = site.resolve("/dir")
assert isinstance(doc, Document)
assert b"# Nested" in doc.gemtext
def test_md_extension_redirects_to_canonical_url(self, site: Site):
with pytest.raises(Redirect) as excinfo:
site.resolve("/about.md")
assert excinfo.value.location == "/about"
def test_missing_md_extension_is_not_found(self, site: Site):
with pytest.raises(NotFound):
site.resolve("/nonexistent.md")
def test_raw_file_is_served_with_mime_type(self, site: Site):
raw = site.resolve("/img.png")
assert isinstance(raw, RawFile)
assert raw.mime == "image/png"
assert raw.data.startswith(b"\x89PNG")
def test_missing_path_is_not_found(self, site: Site):
with pytest.raises(NotFound):
site.resolve("/nope")
def test_bare_unresolvable_segment_is_not_a_root_card(self, site: Site):
# Regression: rpartition("/") on a bare top-level segment like
# "nope" returns sep="" and parent="" -- which must not be
# misread as "card 'nope' of the root index".
with pytest.raises(NotFound):
site.resolve("/totally-unresolvable-segment")
class TestWmlCardUrls:
def test_card_subpath_returns_wml_card(self, site: Site):
result = site.resolve("/trail/weather")
assert isinstance(result, WmlCard)
assert b"Cold and clear" in result.wml
assert b"$$5" in result.wml # WML's own literal-$ escaping, correctly applied
def test_unknown_card_redirects_to_parent(self, site: Site):
with pytest.raises(Redirect) as excinfo:
site.resolve("/trail/nonexistent-card")
assert excinfo.value.location == "/trail"
def test_card_url_of_non_deck_per_card_document_is_not_found(self, site: Site):
# about.md exists but never opted into deck_per_card, so it has no
# wml_cards at all -- /about/whatever must not resolve as a card.
with pytest.raises(NotFound):
site.resolve("/about/whatever")
def test_directive_line_does_not_leak_into_gemtext(self, site: Site):
# md2txt's parser has no concept of wapdown's {.card} directive; it
# must be stripped before gemtext rendering, not passed through as
# literal paragraph text.
doc = site.resolve("/trail")
assert isinstance(doc, Document)
assert b"{.card" not in doc.gemtext
assert b"Cold and clear" in doc.gemtext
assert b"North: open" in doc.gemtext
def test_directive_line_does_not_leak_into_xhtml(self, site: Site):
doc = site.resolve("/trail")
assert b"{.card" not in doc.xhtml
class TestLiveReload:
def test_edited_file_is_rerendered(self, tmp_path: Path):
target = tmp_path / "index.md"
target.write_text("# First\n", encoding="utf-8")
site = Site(tmp_path)
first = site.resolve("/")
assert b"# First" in first.gemtext
# mtime granularity on some filesystems is coarse; force a change.
new_time = target.stat().st_mtime + 5
target.write_text("# Second\n", encoding="utf-8")
os.utime(target, (new_time, new_time))
second = site.resolve("/")
assert b"# Second" in second.gemtext
assert b"# First" not in second.gemtext
def test_unchanged_file_reuses_cache(self, tmp_path: Path):
target = tmp_path / "index.md"
target.write_text("# Same\n", encoding="utf-8")
site = Site(tmp_path)
first = site.resolve("/")
second = site.resolve("/")
assert first is second