Files
homelable/backend/tests/test_doc_tree.py
T
Pouzor f0b5175155 fix(docs): stop the frontmatter regex going quadratic on an open block
CodeQL, high: a polynomial regular expression on uncontrolled data. Both
frontmatter parsers fenced the block with `\s*`, and `\s` matches the newline
the fence line ends with — so the closing alternative was reachable two ways
and every added line multiplied the backtracking. The pathological input is
a body that opens `---` and never closes it, which is what a document looks
like for as long as the user is typing that block: 20k lines took 2.4s, and
the body is parsed on every save.

Fencing on `[ \t]*` instead leaves one way to reach the newline and the same
input takes no measurable time. A trailing space on the fence still parses;
what is no longer accepted is a fence followed by blank lines before the YAML,
which nothing writes.

Both copies, and a timing regression test each side.

ha-relevant: yes
2026-09-07 10:38:24 +02:00

154 lines
5.8 KiB
Python

"""Slugs, frontmatter parsing, and the two tree guards."""
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Document
from app.services import doc_tree
def _doc(**kwargs) -> Document:
base = dict(kind="folder", title="T", slug="t", body="")
base.update(kwargs)
return Document(**base)
# ── slugs ───────────────────────────────────────────────────────────────────
def test_slugify_lowercases_and_collapses_punctuation():
assert doc_tree.slugify("NAS-01 / main storage!") == "nas-01-main-storage"
def test_slugify_of_nothing_is_untitled():
assert doc_tree.slugify("") == "untitled"
assert doc_tree.slugify(" ") == "untitled"
assert doc_tree.slugify("!!!") == "untitled"
def test_slugify_truncates_a_long_title():
assert len(doc_tree.slugify("x" * 200)) == 80
async def test_unique_slug_suffixes_a_taken_sibling(db_session: AsyncSession):
db_session.add(_doc(title="Network", slug="network"))
await db_session.flush()
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-2"
async def test_unique_slug_keeps_counting_past_the_second(db_session: AsyncSession):
db_session.add(_doc(slug="network"))
db_session.add(_doc(slug="network-2"))
await db_session.flush()
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-3"
async def test_unique_slug_only_looks_at_siblings(db_session: AsyncSession):
parent = _doc(title="Folder", slug="folder")
db_session.add(parent)
await db_session.flush()
db_session.add(_doc(slug="network", parent_id=parent.id))
await db_session.flush()
# Same slug, different parent — no clash.
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network"
async def test_unique_slug_ignores_the_document_being_renamed(db_session: AsyncSession):
doc = _doc(title="Network", slug="network")
db_session.add(doc)
await db_session.flush()
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None, exclude_id=doc.id) == "network"
# ── frontmatter ─────────────────────────────────────────────────────────────
def test_parse_frontmatter_reads_the_leading_block():
body = "---\ntitle: NAS\ntags: [a, b]\n---\n\n# NAS\n"
assert doc_tree.parse_frontmatter(body) == {"title": "NAS", "tags": ["a", "b"]}
def test_parse_frontmatter_ignores_a_block_that_is_not_first():
assert doc_tree.parse_frontmatter("# Title\n\n---\ntitle: NAS\n---\n") == {}
def test_parse_frontmatter_of_malformed_yaml_is_empty_not_an_error():
# The user is mid-edit; the cache goes stale, the save does not fail.
assert doc_tree.parse_frontmatter("---\ntitle: [unclosed\n---\nbody") == {}
def test_parse_frontmatter_of_a_non_mapping_is_empty():
assert doc_tree.parse_frontmatter("---\n- a\n- b\n---\n") == {}
def test_parse_frontmatter_of_an_empty_body_is_empty():
assert doc_tree.parse_frontmatter("") == {}
def test_parse_frontmatter_keeps_a_trailing_space_on_the_fence():
assert doc_tree.parse_frontmatter("--- \ntitle: NAS\n--- \n") == {"title": "NAS"}
def test_parse_frontmatter_of_an_unclosed_block_does_not_go_quadratic():
"""A body that opens a block and never closes it must still be linear.
The shape a document has while the block is being typed, and the input the
scanner named: `---\n` then many repetitions of `\n `. With `\s*` around the
fences the closing alternative was reachable two ways and each added line
multiplied the backtracking.
"""
import time
body = "---\n" + "\n " * 40_000
started = time.perf_counter()
assert doc_tree.parse_frontmatter(body) == {}
assert time.perf_counter() - started < 1.0
def test_tags_accepts_a_list_or_a_comma_string():
assert doc_tree.tags_from({"tags": ["a", " b "]}) == ["a", "b"]
assert doc_tree.tags_from({"tags": "a, b"}) == ["a", "b"]
assert doc_tree.tags_from({"tags": 7}) == []
assert doc_tree.tags_from({}) == []
# ── tree guards ─────────────────────────────────────────────────────────────
async def _chain(db_session: AsyncSession, depth: int) -> list[Document]:
docs: list[Document] = []
parent_id = None
for i in range(depth):
doc = _doc(title=f"L{i}", slug=f"l{i}", parent_id=parent_id)
db_session.add(doc)
await db_session.flush()
docs.append(doc)
parent_id = doc.id
return docs
async def test_is_ancestor_walks_the_whole_chain(db_session: AsyncSession):
a, b, c = await _chain(db_session, 3)
assert await doc_tree.is_ancestor(db_session, a.id, c.id)
assert not await doc_tree.is_ancestor(db_session, c.id, a.id)
async def test_a_document_is_its_own_ancestor(db_session: AsyncSession):
(a,) = await _chain(db_session, 1)
assert await doc_tree.is_ancestor(db_session, a.id, a.id)
async def test_subtree_ids_collects_every_descendant(db_session: AsyncSession):
a, b, c = await _chain(db_session, 3)
sibling = _doc(title="S", slug="s", parent_id=a.id)
db_session.add(sibling)
await db_session.flush()
ids = await doc_tree.subtree_ids(db_session, a.id)
assert set(ids) == {a.id, b.id, c.id, sibling.id}
# Root first, so a caller deleting in reverse removes leaves first.
assert ids[0] == a.id
async def test_subtree_ids_of_a_leaf_is_just_itself(db_session: AsyncSession):
(a,) = await _chain(db_session, 1)
assert await doc_tree.subtree_ids(db_session, a.id) == [a.id]