feat: initial implementation
This commit is contained in:
commit
c3cb1d7046
19 changed files with 1296 additions and 0 deletions
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from smolweb.site import Site
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def site(tmp_path: Path) -> Site:
|
||||
(tmp_path / "index.md").write_text("# Home\n\nHello.\n", encoding="utf-8")
|
||||
(tmp_path / "about.md").write_text("---\ntitle: About\n---\n# About\n\nBody.\n", encoding="utf-8")
|
||||
(tmp_path / "img.png").write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
sub = tmp_path / "dir"
|
||||
sub.mkdir()
|
||||
(sub / "index.md").write_text("# Nested\n\nNested body.\n", encoding="utf-8")
|
||||
(tmp_path / ".secret.md").write_text("# Hidden\n", encoding="utf-8")
|
||||
(tmp_path / "trail.md").write_text(
|
||||
"---\ntitle: Trail\ndeck_per_card: true\n---\n"
|
||||
"Intro line.\n\n"
|
||||
"{.card Weather}\n"
|
||||
"Cold and clear. Permit fee: $5.\n\n"
|
||||
"{.card Status}\n"
|
||||
"- North: open\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return Site(tmp_path)
|
||||
48
tests/test_negotiate.py
Normal file
48
tests/test_negotiate.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Accept-header negotiation between WML and XHTML-MP."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from smolweb.negotiate import FORMAT_HTML, FORMAT_WML, FORMAT_XHTML, negotiate
|
||||
|
||||
|
||||
class TestWildcardNeverSelectsWml:
|
||||
def test_modern_browser_accept_header_gets_html(self):
|
||||
# The exact header Firefox/Chrome send: a browser must never be
|
||||
# handed WML through the */* wildcard.
|
||||
accept = "text/html,application/xhtml+xml;q=0.9,image/webp,*/*;q=0.8"
|
||||
assert negotiate(accept) == FORMAT_HTML
|
||||
|
||||
def test_bare_wildcard_gets_html(self):
|
||||
assert negotiate("*/*") == FORMAT_HTML
|
||||
|
||||
def test_no_accept_header_gets_html(self):
|
||||
assert negotiate(None) == FORMAT_HTML
|
||||
|
||||
|
||||
class TestLiteralMatches:
|
||||
def test_wml_accept_header_gets_wml(self):
|
||||
assert negotiate("text/vnd.wap.wml") == FORMAT_WML
|
||||
|
||||
def test_xhtml_mp_accept_header_gets_xhtml(self):
|
||||
assert negotiate("application/vnd.wap.xhtml+xml") == FORMAT_XHTML
|
||||
|
||||
def test_wml_wins_when_both_present_with_higher_q(self):
|
||||
accept = "text/vnd.wap.wml;q=1.0, application/vnd.wap.xhtml+xml;q=0.5"
|
||||
assert negotiate(accept) == FORMAT_WML
|
||||
|
||||
def test_xhtml_wins_when_both_present_with_higher_q(self):
|
||||
accept = "text/vnd.wap.wml;q=0.5, application/vnd.wap.xhtml+xml;q=1.0"
|
||||
assert negotiate(accept) == FORMAT_XHTML
|
||||
|
||||
def test_unrelated_type_gets_html_fallback(self):
|
||||
assert negotiate("application/json") == FORMAT_HTML
|
||||
|
||||
|
||||
class TestFormatOverride:
|
||||
@pytest.mark.parametrize("fmt", [FORMAT_WML, FORMAT_XHTML, FORMAT_HTML])
|
||||
def test_override_wins_regardless_of_accept(self, fmt):
|
||||
assert negotiate("*/*", format_override=fmt) == fmt
|
||||
|
||||
def test_invalid_override_is_ignored(self):
|
||||
assert negotiate("text/vnd.wap.wml", format_override="bogus") == FORMAT_WML
|
||||
141
tests/test_site.py
Normal file
141
tests/test_site.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue