diff --git a/CLAUDE.md b/CLAUDE.md index bf06fb6..0dc1936 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,8 @@ Global rules are imported, not restated: - Read-only over Cortex's store: this server never writes memories. - The plugin install is live-mounted onto this clone; respawn the standalone HTTP server after editing Python or the running process keeps the old code. - Layers: core / server / infrastructure / handlers / hooks / shared / errors. +- **`uv sync --frozen` alone does not install `pytest` or `mutmut`** — they live in the `dev` optional-dependency group (`pyproject.toml`'s `[project.optional-dependencies] dev`), and a bare `--frozen` sync skips every extra. Always run `uv sync --frozen --extra dev` in a fresh worktree/clone before testing. + - **Symptom, not just the fix**: this fails *silently*, not loudly. `uv run pytest` still runs — it just falls through PATH to a system `pytest` (e.g. `/opt/homebrew/bin/pytest`, a different Python than `.venv`'s) instead of erroring "pytest not found". The tell is `uv run which pytest` resolving outside `.venv/bin/`, or DB-backed test modules (`test_memory_read.py`, `test_no_db_mode.py`, ...) failing collection with `ModuleNotFoundError: No module named 'psycopg'` even though `uv run python -c "import psycopg"` succeeds — the module-level import works fine standalone; only the wrong pytest interpreter can't see it. A scoped run against a couple of test files can look completely green while the full suite silently loses its collection of every DB-touching module — the whole point of `--frozen` (a reproducible, verifiable environment) is defeated by exactly the case it's meant to prevent. ## Etiquette diff --git a/tests/MUTATION_NOTES.md b/tests/MUTATION_NOTES.md new file mode 100644 index 0000000..dcf2643 --- /dev/null +++ b/tests/MUTATION_NOTES.md @@ -0,0 +1,243 @@ +# Mutation testing — Python scoped-run notes (issue #74) + +Per coding-standards §12, mutation testing (not line coverage) is the strength +gate on changed logic. This documents the scoped `mutmut` runs closing +issue #74's pre-existing backlog (136 survivors, discovered while wiring +`py/path-injection` fixes — see the issue for the original breakdown) in +the three path-guard modules, and triages every surviving mutant: killed, or +documented-equivalent. No survivor is left un-triaged. Format follows the +existing JS precedent (`tests/js/MUTATION_NOTES.md`). + +Run: `scripts/mutation_check.sh ` (mutmut 3.x, capped at +`--max-children 3` for these runs — the machine was shared with another +session's measurement campaign; see the PR for load/disk snapshots per run). + +## `cortex_viz/infrastructure/wiki_read.py` + +Tests: `tests/test_wiki_read.py`. **362 mutants generated, 355 killed, 7 +equivalent, 0 unexplained survivors** (was 67 survivors before this change). + +### Equivalent mutants (documented, not ignored) + +- **`x__page_item__mutmut_11`, `x_read_page__mutmut_25`, + `x_list_bibliography__mutmut_23`, `x_read_bibliography__mutmut_25`, + `x_save_page__mutmut_24` — `encoding="utf-8"` → `encoding="UTF-8"`.** + Python's codec registry normalises encoding names case-insensitively + (confirmed: `codecs.lookup("UTF-8") is codecs.lookup("utf-8")` → + `True`). The mutated call resolves to the exact same codec object as the + original; no input can distinguish them. Equivalent for every observer. + +- **`x_list_bibliography__mutmut_18`, `x_list_bibliography__mutmut_20` — + `encoding="utf-8"` dropped / set to `None` on the `.bib` read.** Unlike + the other four functions in this module, `list_bibliography`'s decoded + `text` is never returned to the caller — only `text.count("\n@")` and + `text.lstrip().startswith("@")` are ever inspected, and both operations + only look for `'@'`, `'\n'`, and whitespace, which are single-byte ASCII + bytes (0x40, 0x0A, ...) that decode identically to the same characters + under every ASCII-compatible encoding this codebase runs on (UTF-8, + Latin-1, the platform-default fallback, ...). Combined with + `errors="replace"` (untouched by these two mutants, so no exception is + possible), a wrong encoding can only corrupt non-ASCII byte sequences + elsewhere in the string — which affects neither the entry count nor the + leading-entry check nor `size` (`bib.stat().st_size`, a filesystem + property independent of how the bytes are later decoded). Verified this + is not merely "hard to trigger" but structurally unobservable: every + consumer of the decoded value is ASCII-only. + +### Notable kills — the *why*, not just the assertion + +- **`x__page_item__mutmut_6/7/8/9/12/13`, `x_read_page__mutmut_20/21/22/23/26/27`, + `x_list_bibliography__mutmut_19/21/24/25`, `x_read_bibliography__mutmut_20/21/22/23/26/27`, + `x_save_page__mutmut_20/22` + — the `errors="replace"` / `encoding="utf-8"` keyword pair on every + `read_text`/`write_text` call, dropped, set to `None`, or given an + invalid handler name (`"REPLACE"`, mutmut's `"XX...XX"` marker + wrapping).** Two distinct probes, chosen because the failure modes + differ: + - *Invalid/absent error handler*: a fixture file containing a raw + invalid UTF-8 byte (`\xff`) forces the handler to actually engage. + `errors=None` (≡ `strict`) raises `UnicodeDecodeError`; an unknown + handler name (`"REPLACE"`, `"XXreplaceXX"` — verified via + `codecs.lookup_error`, both fail lookup) raises `LookupError`. + Neither is an `OSError`, so neither is caught by the function's + `except OSError:` — the mutant crashes instead of gracefully + replacing the byte. One fixture file kills every variant of this + mutation simultaneously. + - *Wrong/absent encoding*: `locale.setlocale(locale.LC_ALL, "C")` + called **in-process** (not via a subprocess — see below) forces the + C library's preferred encoding to US-ASCII for the duration of the + test, then a page/bib file with a real non-ASCII character (`café`) + is read or written. The pinned `encoding="utf-8"` round-trips it + correctly regardless; `encoding=None` (or the kwarg dropped, which + defaults to the same `None`) decodes/encodes it wrong (`read_text` + replaces the multi-byte sequence with `U+FFFD` twice; `write_text` + raises `UnicodeEncodeError`, again uncaught by `except OSError:`). + +- **A subprocess-with-`LC_ALL=C` approach was tried first and abandoned.** + It reproduces the real divergence (verified manually: a forced + `LC_ALL=C` subprocess genuinely garbles `café` without the pin), but + mutmut selects which tests to run against a given mutant by tracing + which lines a test's *own process* executes during its coverage/stats + pass — a spawned child process's execution is invisible to that trace. + A subprocess-based test is therefore silently never scheduled against + the mutant it exists to kill: it passes in isolation, yet the mutant + it targets still reports "survived" in the real run (reproduced this + exact symptom before switching approaches). `locale.setlocale` changes + the *same* process's C-library locale state at runtime — verified that + `Path.read_text`/`Path.write_text` genuinely consult it, whereas + monkeypatching the higher-level `locale.getencoding()` / + `locale.getpreferredencoding()` Python functions does **not** reach + them (both tried and shown ineffective) — so it is both a real + behavioural probe and one mutmut's coverage tracer can see. + +- **`x__page_item__mutmut_70/71/72` (`meta.get("updated")` → + `meta.get(None)` / `"updated"` mutmut-marker-wrapped / `"UPDATED"`)** — + killed by a fixture with distinct `updated` and `tended` values, + asserting the `updated` one wins (the same precedence pattern already + covered for `created`-over-`date`). + +- **`x_read_page__mutmut_14–39` and the `x_save_page__mutmut_31/32` / + `x_read_bibliography__mutmut_28–30` "dict key" mutants** (`"error"` → + `"ERROR"`, `"path"` → `"PATH"`, etc.) — the individual-field assertions + already in the suite (`got["meta"]["tags"]`, …) don't pin the *exact* + key set; added full-dict-equality assertions on the success and + not-found/OSError response shapes, which pin every key name and value + simultaneously. + +- **`x_list_bibliography__mutmut_32` (`text.lstrip()` → `text.rstrip()` + in the leading-entry check)** — killed with a `.bib` fixture whose + first entry is preceded by *leading whitespace* (not a `\n@` pattern): + `lstrip()` strips it and the entry is counted; `rstrip()` (which only + touches the trailing end) leaves it and the entry is missed. + +- **`x_list_bibliography__mutmut_34` (`else 0` → `else 1` in the leading- + entry check)** — killed with a `.bib` fixture containing zero `@` + entries at all; the mutant unconditionally reports one anyway. + +- **`x_list_bibliography__mutmut_44` (`except OSError: continue` → + `break`)** — killed with two `.bib` files where the *first* (in sorted + order) raises `OSError` on read; `continue` reaches the second file, + `break` silently drops it from the result. + +- **`x_save_page__mutmut_3/5`, `x_read_bibliography__mutmut_3/5` + (`_safe_path(..., suffix=".md"/".bib")` → `suffix=None`, or the kwarg + dropped)** — killed by asserting the wrong-suffix refusal directly + (`save_page("notes.txt", ...)`, `read_bibliography(".../notes.txt")`), + which the suite exercised for `read_page` but not for these two + siblings. + +## `cortex_viz/server/http_standalone_static.py` + +Tests: `tests/test_static_path_traversal.py`. **152 mutants generated, 147 +killed, 5 equivalent, 0 unexplained survivors** (was 53 survivors before +this change). + +### Equivalent mutants (documented, not ignored) + +- **`x_serve_static__mutmut_4/5/8/9` — the `or`-chain in `serve_static`'s + filename guard** (`startswith(".") or "\x00" in name` fused into `and`, + or either literal turned into a mutmut marker string that can never + match a real filename). `serve_static`'s guard ends with + `re.match(r"^[\w][\w.\-]*$", safe_name)`, and this regex **already + independently implies** every property the earlier clauses check: + empty (`^[\w]` requires one char), dot-prefixed (`.` is never `\w`), + and null-byte-containing (`\x00` is in none of `\w`, `.`, `-`) names + all fail the regex on their own. Verified directly — + `re.compile(r"^[\w][\w.\-]*$").match(x)` returns `False` for `''`, + `'.'`, `'.hidden'`, and every string containing `'\x00'`, tested + exhaustively for the relevant classes. Weakening or disabling the + earlier clauses changes nothing observable: the regex is the load- + bearing check; the clauses ahead of it are early-exit optimizations + over an already-total condition, not independently-observable guards. + +- **`x_serve_shared_asset__mutmut_10` — `part in ("", "..")` with `".."` + replaced by a mutmut marker string that can never match a real path + segment.** The same segment-rejection `any(...)` also checks + `part.startswith(".")`, and `".."` trivially satisfies that (every + string starting with two dots starts with one). Any segment equal to + literal `".."` is therefore always also caught by the dot-prefix + clause; the explicit `".."` membership check is redundant for that + specific value. (Distinct from the OR→AND fusion mutant on this same + line, which killed cleanly — that one disables both clauses + simultaneously for a plain empty segment, which does NOT start with + `"."` and has no other catching clause.) + +### Notable kills — the *why*, not just the assertion + +- **`x_serve_static__mutmut_36`, `x_serve_shared_asset__mutmut_19` (403 → + 404 on the containment/segment-rejection refusal paths)** — the existing + traversal-payload tests asserted `status in (403, 404)` (both are "safe" + outcomes), which cannot distinguish a deliberate refusal from a plain + not-found. Added exact-403 assertions for a symlink escape (`serve_static`) + and an empty `rel_path` (`serve_shared_asset`) specifically. + +- **`x_serve_shared_asset__mutmut_7/9/12/13/14` + (the `any(part in ("", "..") or part.startswith(".") for part in + rel_path.split("/"))` segment-rejection predicate — OR fused to AND, + each literal replaced by a dead marker string, and the split delimiter + itself replaced by `None` or a dead marker)** — killed by three + payloads chosen so each nets to a *legitimately contained* file if the + pre-check is bypassed, proving the pre-check is genuine defense-in-depth + and not redundant with `resolve_under`'s containment check alone: a + dot-prefixed file that actually exists in the sandbox + (`.hidden-but-real.css`), a doubled separator that POSIX would collapse + to a real file (`tokens//colors.css`), and a literal `..` segment that + nets back inside the sandbox (`tokens/../ds.css`). All three must be + refused with exactly 403 even though `resolve_under` alone would have + accepted the resolved path. + +- **`x_serve_file_diff__mutmut_1/2/3/4` (`_serve(None, store)`, + `_serve(handler, None)`, `_serve(store)` — wrong argument, dropped + argument, wrong arity) — 0 tests were associated with this function at + all before this change.** `serve_file_diff` here is a documented thin + delegate to `http_file_diff.serve_file_diff`; its entire observable + contract is "forwards both arguments intact." Killed with two tests + that route a bare-basename query through the delegate: one with + `store=None` (yields `"unresolved basename: activity store + unavailable"`), one with a real store object (`store=object()`, yields + the *different* `"unresolved basename: activity store lookup failed"` + reason via `_resolve_by_basename`'s real lookup-exception arm). Reaching + either reason at all rules out `handler=None` (which crashes resolving + `handler.path` before any JSON is written); the reason *differing* + between the two calls rules out `store` being dropped or defaulted. + +## `cortex_viz/server/http_file_diff.py` + +Tests: `tests/test_git_diff_engine.py,tests/test_file_diff.py`. **120 +mutants generated, 120 killed, 0 equivalent, 0 survivors** (was 16 +survivors before this change). + +### Notable kills — the *why*, not just the assertion + +- **`x__resolve_by_basename__mutmut_5`, `x__resolve_by_relative_fragment__mutmut_23`, + `x__resolve_name__mutmut_7`, `x_serve_file_diff__mutmut_33` + (`store` swapped for `None` at four distinct call sites along the + resolution chain: `_resolve_by_basename` -> `find_abs_path_by_label`, + `_resolve_by_relative_fragment` -> `find_abs_path_by_suffix`, + `_resolve_name` -> `_resolve_by_relative_fragment`, and + `serve_file_diff` -> `_resolve_name`)** — the existing store-forwarding + tests monkeypatched the lookup functions with lambdas that *ignored* + their `store` parameter, so a dropped/`None`-swapped store was + invisible to them. Each of the four call sites needed its own + independent proof: a lambda/closure that records every `store` value it + actually received and asserts the list equals `[sentinel_store]` — a + passed-through `None` swap shows up as `[None]` or a length mismatch + instead. + +- **`x__resolve_by_basename__mutmut_11` (`"unresolved basename: not found + in activity index"` wrapped in mutmut's `"XX...XX"` marker)** — the + existing test asserted `"unresolved basename" in reason` (substring), + which the wrapped string still contains. Tightened to exact string + equality, which the JS notes precedent already established as the + right default for reason/error strings in this codebase. + +- **`x_serve_file_diff__mutmut_49/50/51/52/53` (the "unresolved name" + response dict's `"lines"`/`"truncated"` keys and the `False` -> `True` + value mutant)** — the existing test asserted three of the five keys + individually (`available`, `diff_type`, `reason`), missing `lines` and + `truncated` entirely. Switched to full-dict equality against the + literal five-key response, matching the pattern already used for the + "no file given" branch's equivalent full-dict test. + +No equivalent mutants in this module — every survivor from the original +run was a genuine test gap, not an unobservable difference. diff --git a/tests/test_file_diff.py b/tests/test_file_diff.py index 57ecbb3..1b0ad7c 100644 --- a/tests/test_file_diff.py +++ b/tests/test_file_diff.py @@ -10,14 +10,15 @@ import subprocess from pathlib import Path +from typing import ClassVar +import cortex_viz.server.http_file_diff as hfd from cortex_viz.server.git_diff_engine import ( _MAX_LINES, _full_content_as_adds, _parse_unified, _resolve_diff, ) -from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment _SAMPLE = """diff --git a/f.txt b/f.txt index 1234567..89abcde 100644 @@ -101,6 +102,244 @@ def test_resolve_diff_type_selection(tmp_path: Path): def test_resolve_by_relative_fragment_rejects_path_traversal(): # A '..' segment must never be joined onto a repo root — otherwise a # crafted ``name`` query param could escape the repo (CWE-22). - abs_path, reason = _resolve_by_relative_fragment(None, "../../etc/passwd") + abs_path, reason = hfd._resolve_by_relative_fragment(None, "../../etc/passwd") assert abs_path is None assert reason == "unresolved relative name: path traversal rejected" + + +# ── _resolve_by_basename / _resolve_by_relative_fragment failure arms ──── +# The happy paths and the "store unavailable" arms are covered via +# ``_resolve_name`` in test_git_diff_engine.py; these pin the two arms only +# reachable when a store IS present: the activity-store lookup raising, and +# (for the relative-fragment resolver) the suffix-search fallback. + + +def test_resolve_by_basename_store_lookup_exception_reports_reason(monkeypatch): + import cortex_viz.infrastructure.activity_store as activity_store + + def _boom(store, label): + raise RuntimeError("db down") + + monkeypatch.setattr(activity_store, "find_abs_path_by_label", _boom) + abs_path, reason = hfd._resolve_by_basename(object(), "foo.py") + assert abs_path is None + assert reason == "unresolved basename: activity store lookup failed" + + +def test_resolve_by_relative_fragment_store_none_after_no_repo_match_reports_reason(): + # No registry repo matches and store is None -- distinct message from + # the "no such basename" and "activity store lookup failed" arms. + abs_path, reason = hfd._resolve_by_relative_fragment(None, "no/such/repo/file.py") + assert abs_path is None + assert reason == "unresolved relative name: not found in known repos" + + +def test_resolve_by_relative_fragment_falls_back_to_activity_suffix_search( + monkeypatch, +): + import cortex_viz.infrastructure.activity_store as activity_store + + sentinel_store = object() + seen_stores = [] + + def _fake(store, name): + seen_stores.append(store) + return "/repo/src/found.py" if name == "src/found.py" else None + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _fake) + abs_path, reason = hfd._resolve_by_relative_fragment(sentinel_store, "src/found.py") + assert abs_path == "/repo/src/found.py" + assert reason is None + assert seen_stores == [sentinel_store] + + +def test_resolve_name_forwards_the_exact_store_to_the_relative_fragment_resolver( + monkeypatch, +): + # _resolve_name's relative-path branch delegates to + # _resolve_by_relative_fragment(store, ...) -- distinct call site from + # the basename branch (covered above), and must forward the same store + # object rather than defaulting it to None along the way. + import cortex_viz.infrastructure.activity_store as activity_store + + sentinel_store = object() + seen_stores = [] + + def _fake(store, name): + seen_stores.append(store) + return "/repo/src/found.py" if name == "src/found.py" else None + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _fake) + abs_path, reason = hfd._resolve_name(sentinel_store, "src/found.py") + assert abs_path == "/repo/src/found.py" + assert reason is None + assert seen_stores == [sentinel_store] + + +def test_resolve_by_relative_fragment_suffix_search_not_found_reports_reason( + monkeypatch, +): + import cortex_viz.infrastructure.activity_store as activity_store + + monkeypatch.setattr( + activity_store, "find_abs_path_by_suffix", lambda store, name: None + ) + abs_path, reason = hfd._resolve_by_relative_fragment(object(), "src/missing.py") + assert abs_path is None + assert reason == ( + "unresolved relative name: not found in activity index or known repos" + ) + + +def test_resolve_by_relative_fragment_suffix_search_exception_reports_reason( + monkeypatch, +): + import cortex_viz.infrastructure.activity_store as activity_store + + def _boom(store, name): + raise RuntimeError("db down") + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _boom) + abs_path, reason = hfd._resolve_by_relative_fragment(object(), "src/anything.py") + assert abs_path is None + assert reason == "unresolved relative name: activity store lookup failed" + + +def test_resolve_by_relative_fragment_prefers_known_repo_over_suffix_search( + monkeypatch, tmp_path +): + # When a repo-root candidate exists on disk, the suffix-search fallback + # (and its store) must never even be consulted. + import cortex_viz.infrastructure.activity_store as activity_store + + repo_dir = tmp_path / "known_repo" + (repo_dir / "src").mkdir(parents=True) + (repo_dir / "src" / "file.py").write_text("x\n") + + class _FakeRepoInfo: + def __init__(self, fs_path: str) -> None: + self.fs_path = fs_path + + class _FakeRegistry: + repos: ClassVar[list] = [_FakeRepoInfo(str(repo_dir))] + + monkeypatch.setattr( + "cortex_viz.shared.domain_mapping._build_registry", lambda: _FakeRegistry() + ) + + def _boom(store, name): # pragma: no cover - must never run + raise AssertionError("suffix search must not run when a repo root matches") + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _boom) + abs_path, reason = hfd._resolve_by_relative_fragment(object(), "src/file.py") + assert abs_path == str(repo_dir / "src" / "file.py") + assert reason is None + + +# ── serve_file_diff — the HTTP entry point itself ──────────────────────── + + +class _FakeHandler: + def __init__(self, path: str) -> None: + self.path = path + self.status: int | None = None + self.headers: dict[str, str] = {} + self.body = b"" + self.wfile = self + + def send_response(self, code: int) -> None: + self.status = code + + def send_header(self, k: str, v: str) -> None: + self.headers[k] = v + + def end_headers(self) -> None: + pass + + def write(self, data: bytes) -> None: + self.body += data + + +def _decode_json(handler: _FakeHandler) -> dict: + import json + + return json.loads(handler.body.decode("utf-8")) + + +def test_serve_file_diff_missing_name_param_reports_no_file_given(): + h = _FakeHandler("/api/file-diff") + hfd.serve_file_diff(h) + assert h.status == 200 + payload = _decode_json(h) + assert payload == { + "available": False, + "diff_type": "none", + "lines": [], + "truncated": False, + "reason": "no file given", + } + + +def test_serve_file_diff_unresolvable_name_reports_reason_and_unavailable(): + h = _FakeHandler("/api/file-diff?name=nowhere.py") + hfd.serve_file_diff(h, store=None) + assert h.status == 200 + payload = _decode_json(h) + assert payload == { + "available": False, + "diff_type": "none", + "lines": [], + "truncated": False, + "reason": "unresolved basename: activity store unavailable", + } + + +def test_serve_file_diff_forwards_the_exact_store_to_resolve_name(monkeypatch): + # serve_file_diff's own call to _resolve_name(store, name) is a distinct + # call site from every store-forwarding test above (those exercise the + # resolver functions directly) -- must not default/drop `store` at the + # top of the HTTP entry point itself. + sentinel_store = object() + seen_stores = [] + + def _fake_resolve_name(store, name): + seen_stores.append(store) + return None, "stub reason" + + monkeypatch.setattr(hfd, "_resolve_name", _fake_resolve_name) + h = _FakeHandler("/api/file-diff?name=foo.py") + hfd.serve_file_diff(h, store=sentinel_store) + assert seen_stores == [sentinel_store] + + +def test_serve_file_diff_resolves_absolute_path_and_delegates_to_the_engine( + tmp_path: Path, +): + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "-C", str(root), "init", "-q"], check=True) + (root / "new.txt").write_text("alpha\n") + + h = _FakeHandler(f"/api/file-diff?name={root / 'new.txt'}") + hfd.serve_file_diff(h) + assert h.status == 200 + payload = _decode_json(h) + assert payload["available"] is True + assert payload["diff_type"] == "untracked" + + +def test_serve_file_diff_unexpected_exception_yields_json_error(): + class _BrokenHandler(_FakeHandler): + @property + def path(self): # noqa: D401 - raising on access forces the except branch + raise RuntimeError("boom") + + @path.setter + def path(self, value): + pass + + h = _BrokenHandler("/api/file-diff?name=x") + hfd.serve_file_diff(h) + assert h.status == 500 + payload = _decode_json(h) + assert payload == {"error": "RuntimeError"} diff --git a/tests/test_git_diff_engine.py b/tests/test_git_diff_engine.py index 3c9bf19..bb0a77a 100644 --- a/tests/test_git_diff_engine.py +++ b/tests/test_git_diff_engine.py @@ -358,14 +358,21 @@ def test_resolve_name_basename_resolves_via_activity_store(monkeypatch): import cortex_viz.infrastructure.activity_store as activity_store from cortex_viz.server.http_file_diff import _resolve_name - monkeypatch.setattr( - activity_store, - "find_abs_path_by_label", - lambda store, label: "/Users/dev/repo/foo.py" if label == "foo.py" else None, - ) - abs_path, reason = _resolve_name(object(), "foo.py") + sentinel_store = object() + seen_stores = [] + + def _fake(store, label): + seen_stores.append(store) + return "/Users/dev/repo/foo.py" if label == "foo.py" else None + + monkeypatch.setattr(activity_store, "find_abs_path_by_label", _fake) + abs_path, reason = _resolve_name(sentinel_store, "foo.py") assert abs_path == "/Users/dev/repo/foo.py" assert reason is None + # The exact store object must cross every layer (_resolve_name -> + # _resolve_by_basename -> find_abs_path_by_label) unchanged, not be + # swapped for None along the way. + assert seen_stores == [sentinel_store] def test_resolve_name_basename_unresolved_reports_reason(monkeypatch): @@ -379,8 +386,7 @@ def test_resolve_name_basename_unresolved_reports_reason(monkeypatch): ) abs_path, reason = _resolve_name(object(), "missing.py") assert abs_path is None - assert reason is not None - assert "unresolved basename" in reason + assert reason == "unresolved basename: not found in activity index" def test_resolve_name_never_falls_back_to_server_cwd(monkeypatch): diff --git a/tests/test_static_path_traversal.py b/tests/test_static_path_traversal.py index 2b15e3b..c2db8b6 100644 --- a/tests/test_static_path_traversal.py +++ b/tests/test_static_path_traversal.py @@ -204,3 +204,242 @@ def test_http_server_static_reader_still_serves_legitimate_files( _serve_static(h, flat_dir, "config.js", "application/javascript") assert h.status == 200 assert h.body == b"// cfg" + + +# ── response framing — headers are part of the contract, not incidental ── +# A body-only assertion cannot distinguish "sent the right Content-Type" from +# "sent an empty/wrong one" -- these pin the framing headers both readers +# promise on a 200. + + +def test_static_success_sends_correct_framing_headers(flat_dir: Path) -> None: + h = FakeHandler() + serve_static(h, flat_dir, "config.js", "application/javascript") + assert h.status == 200 + assert h.headers["Content-Type"] == "application/javascript; charset=utf-8" + assert h.headers["Content-Length"] == str(len(b"// cfg")) + assert h.headers["Cache-Control"] == "no-cache" + + +def test_shared_asset_success_sends_correct_framing_headers(sandbox: Path) -> None: + h = FakeHandler() + serve_shared_asset(h, sandbox, "tokens/colors.css") + assert h.status == 200 + body = b"/* colors */" + assert h.headers["Content-Type"] == "text/css; charset=utf-8" + assert h.headers["Content-Length"] == str(len(body)) + assert h.headers["Cache-Control"] == "no-cache" + + +# ── shared-asset content-type table — every mapped extension, plus the +# unmapped fallback, pinned individually (a swapped dict value produces +# a wrong-but-still-served response no status/body assertion catches) ── + + +@pytest.mark.parametrize( + ("filename", "expected_type"), + [ + ("f.css", "text/css"), + ("f.js", "application/javascript"), + ("f.mjs", "application/javascript"), + ("f.json", "application/json"), + ("f.woff2", "font/woff2"), + ("f.woff", "font/woff"), + ("f.ttf", "font/ttf"), + ("f.svg", "image/svg+xml"), + ("f.unknownext", "text/plain"), + ], +) +def test_shared_asset_content_type_table( + tmp_path: Path, filename: str, expected_type: str +) -> None: + shared = tmp_path / "shared" + shared.mkdir() + (shared / filename).write_bytes(b"data") + h = FakeHandler() + serve_shared_asset(h, shared, filename) + assert h.status == 200 + assert h.headers["Content-Type"] == f"{expected_type}; charset=utf-8" + + +def test_shared_asset_content_type_lookup_is_case_insensitive_on_suffix( + tmp_path: Path, +) -> None: + shared = tmp_path / "shared" + shared.mkdir() + (shared / "f.CSS").write_bytes(b"data") + h = FakeHandler() + serve_shared_asset(h, shared, "f.CSS") + assert h.status == 200 + assert h.headers["Content-Type"] == "text/css; charset=utf-8" + + +# ── serve_static filename-whitelist regex — each admitted/rejected class +# of first character and body character, pinned independently ──────── + + +@pytest.mark.parametrize( + ("filename", "should_serve"), + [ + ("123.js", True), # digit-led name is a legal identifier + ("_private.js", True), # underscore-led name is a legal identifier + ("-leading-dash.js", False), # '-' is not \w -- rejected as first char + ("a b.js", False), # embedded space is not in the allowed class + ], +) +def test_static_filename_whitelist_boundary( + tmp_path: Path, filename: str, should_serve: bool +) -> None: + d = tmp_path / "js" + d.mkdir() + (d / filename).write_text("x", encoding="utf-8") + h = FakeHandler() + serve_static(h, d, filename, "application/javascript") + if should_serve: + assert h.status == 200 + assert h.body == b"x" + else: + assert h.status == 403 + assert h.body == b"" + + +def test_static_directory_entry_is_not_served(tmp_path: Path) -> None: + """A directory sharing a name with a would-be file must 404, not be + treated as content -- the whitelist is built with ``is_file()`` only.""" + d = tmp_path / "js" + d.mkdir() + (d / "sub").mkdir() + h = FakeHandler() + serve_static(h, d, "sub", "application/javascript") + assert h.status == 404 + assert h.body == b"" + + +def test_static_unknown_filename_is_404_not_403(flat_dir: Path) -> None: + """A name that passes the regex whitelist but names nothing on disk is a + plain 404 (unknown), distinct from a 403 (rejected shape).""" + h = FakeHandler() + serve_static(h, flat_dir, "does-not-exist.js", "application/javascript") + assert h.status == 404 + assert h.body == b"" + + +def test_static_symlink_escape_is_exactly_403_not_404(flat_dir: Path) -> None: + # Distinguishes the containment refusal (403, deliberately rejected) + # from a plain not-found (404) -- both are "safe" outcomes but only one + # is the correct status for a name that WAS found and refused. + (flat_dir / "evil.js").symlink_to(flat_dir.parent / "secret.txt") + h = FakeHandler() + serve_static(h, flat_dir, "evil.js", "application/javascript") + assert h.status == 403 + assert h.body == b"" + + +# ── serve_shared_asset segment-rejection pre-check — the ``or``-chain over +# (empty / ".." / dot-prefixed) is checked PER SEGMENT before containment +# ever runs. resolve_under alone catches actual escapes, so these +# assertions must construct a payload that IS legitimately contained yet +# must still be refused pre-check, as defense-in-depth against ambiguous +# input -- otherwise the pre-check reads as untestable dead code when it +# is not. ──────────────────────────────────────────────────────────── + + +def test_shared_asset_refuses_a_dot_prefixed_segment_even_if_the_file_exists( + sandbox: Path, +) -> None: + (sandbox / ".hidden-but-real.css").write_text("/* h */", encoding="utf-8") + h = FakeHandler() + serve_shared_asset(h, sandbox, ".hidden-but-real.css") + assert h.status == 403, "a dot-prefixed segment must be refused pre-check" + assert h.body == b"" + + +def test_shared_asset_refuses_an_empty_segment_even_if_it_resolves_to_a_real_file( + sandbox: Path, +) -> None: + # A doubled separator produces an empty path segment; POSIX path + # resolution would collapse it to the real, legitimately-contained + # file -- the segment-level pre-check must refuse it before that + # resolution ever happens. + h = FakeHandler() + serve_shared_asset(h, sandbox, "tokens//colors.css") + assert h.status == 403, "an empty path segment must be refused pre-check" + assert h.body == b"" + + +def test_shared_asset_refuses_a_dotdot_segment_even_when_it_nets_to_a_contained_file( + sandbox: Path, +) -> None: + # "tokens/../ds.css" resolves (via resolve_under) to the legitimately + # contained ds.css -- the pre-check must still refuse the literal ".." + # segment outright, never delegating the decision to containment alone. + h = FakeHandler() + serve_shared_asset(h, sandbox, "tokens/../ds.css") + assert h.status == 403, "a literal '..' segment must be refused pre-check" + assert h.body == b"" + + +def test_shared_asset_segment_refusal_is_exactly_403_not_404(sandbox: Path) -> None: + h = FakeHandler() + serve_shared_asset(h, sandbox, "") + assert h.status == 403 + + +# ── serve_file_diff — the delegate must forward BOTH the handler and the +# store intact, not drop either or swap positions. A "no name given" +# smoke call can't distinguish a dropped ``store`` (both produce the +# same reason); using a bare-basename query routes through the +# store-dependent resolver arm, whose reason text differs by whether a +# real store object crossed the delegation boundary. ───────────────── + + +class _FakeDiffHandler: + def __init__(self, path: str) -> None: + self.path = path + self.status: int | None = None + self.headers: dict[str, str] = {} + self.body = b"" + self.wfile = self + + def send_response(self, code: int) -> None: + self.status = code + + def send_header(self, k: str, v: str) -> None: + self.headers[k] = v + + def end_headers(self) -> None: + pass + + def write(self, data: bytes) -> None: + self.body += data + + +def _diff_reason(h: _FakeDiffHandler) -> str: + import json + + return json.loads(h.body)["reason"] + + +def test_serve_file_diff_forwards_the_real_handler_not_none() -> None: + from cortex_viz.server.http_standalone_static import serve_file_diff + + # handler=None would crash resolving handler.path before any JSON is + # ever written -- reaching a clean JSON response at all proves the + # real handler crossed the delegation boundary. + h = _FakeDiffHandler("/api/file-diff?name=foo.py") + serve_file_diff(h, store=None) + assert h.status == 200 + assert _diff_reason(h) == "unresolved basename: activity store unavailable" + + +def test_serve_file_diff_forwards_the_real_store_not_none() -> None: + from cortex_viz.server.http_standalone_static import serve_file_diff + + # A non-None store routes _resolve_by_basename into the lookup-attempt + # arm (a different reason string than the store-absent arm above) -- + # this only happens if `store` genuinely crossed the boundary rather + # than being dropped or defaulted. + h = _FakeDiffHandler("/api/file-diff?name=foo.py") + serve_file_diff(h, store=object()) + assert h.status == 200 + assert _diff_reason(h) == "unresolved basename: activity store lookup failed" diff --git a/tests/test_wiki_read.py b/tests/test_wiki_read.py index 8675453..40b0c0c 100644 --- a/tests/test_wiki_read.py +++ b/tests/test_wiki_read.py @@ -7,6 +7,10 @@ from __future__ import annotations +import contextlib +import locale +from pathlib import Path + import pytest import cortex_viz.infrastructure.wiki_read as mod @@ -19,6 +23,42 @@ def _write_page(tmp_path, rel_path: str, content: str): return p +# ── UTF-8 pinning under a non-UTF-8 process locale ────────────────────── +# Every read_text/write_text call in this module pins encoding="utf-8" +# explicitly rather than trusting the platform default -- the CI matrix +# runs a Windows job specifically because that default is NOT UTF-8 +# everywhere. Killing an "encoding=None" mutant needs an environment where +# the default genuinely differs from UTF-8. +# +# A subprocess with LC_ALL=C reproduces that divergence but is invisible to +# mutmut's coverage-based test-to-mutant association: mutmut decides which +# tests to run for a given mutant by tracing which lines a test's OWN +# process executes during the stats pass, and a spawned child process's +# execution is outside that trace -- a subprocess-based test is silently +# never selected to run against the mutant it targets (verified empirically: +# it passes locally in isolation, yet the mutant it exists to kill still +# reports "survived" in the real run). `locale.setlocale` changes the SAME +# process's C-library locale state at runtime, which `Path.read_text` / +# `Path.write_text` genuinely consult (also verified empirically -- +# monkeypatching the higher-level `locale.getencoding` / +# `locale.getpreferredencoding` Python functions does NOT reach them; only +# the C-level `setlocale` call does) -- so this is both a real behavioural +# probe and one mutmut's coverage tracer can see. + + +@contextlib.contextmanager +def _c_locale(): + """Force the process's C-library locale to "C" (US-ASCII preferred + encoding) for the duration of the block, restoring the prior locale + afterward even on failure.""" + original = locale.setlocale(locale.LC_ALL) + try: + locale.setlocale(locale.LC_ALL, "C") + yield + finally: + locale.setlocale(locale.LC_ALL, original) + + def test_read_page_normalises_tags_to_a_list(monkeypatch, tmp_path): monkeypatch.setattr(mod, "WIKI_ROOT", tmp_path) _write_page( @@ -192,3 +232,540 @@ def test_read_bibliography_refuses_a_path_outside_the_wiki_root(monkeypatch, tmp (tmp_path / "outside.bib").write_text("@book{x}\n", encoding="utf-8") monkeypatch.setattr(mod, "WIKI_ROOT", root) assert mod.read_bibliography("../outside.bib") == {"error": "invalid path"} + + +# ── _parse_list ────────────────────────────────────────────────────────── +# Frontmatter list values arrive in three raw shapes from the flat-KV +# parser: an already-materialised list (only via direct dict construction, +# never from the parser itself, but the function must still honour it), +# a bracketed comma-separated string, and a bare comma-separated string. + + +def test_parse_list_passes_through_a_real_list(): + assert mod._parse_list(["a", " b ", ""]) == ["a", "b"] + + +def test_parse_list_falsy_value_is_empty(): + assert mod._parse_list(None) == [] + assert mod._parse_list("") == [] + + +def test_parse_list_strips_brackets_and_splits_on_comma(): + assert mod._parse_list("[a, b, c]") == ["a", "b", "c"] + + +def test_parse_list_without_brackets_still_splits(): + assert mod._parse_list("a, b") == ["a", "b"] + + +def test_parse_list_strips_quotes_from_each_token(): + assert mod._parse_list("['a', \"b\"]") == ["a", "b"] + + +def test_parse_list_drops_whitespace_only_tokens(): + assert mod._parse_list("a, , b") == ["a", "b"] + + +# ── _title_from ────────────────────────────────────────────────────────── + + +def test_title_from_uses_frontmatter_title_when_present(): + assert mod._title_from({"title": "Explicit Title"}, Path("/x/ignored.md")) == ( + "Explicit Title" + ) + + +def test_title_from_falls_back_to_stem_with_separators_as_spaces(): + assert mod._title_from({}, Path("/x/my-cool_page.md")) == "my cool page" + + +def test_title_from_falls_back_to_filename_when_stem_is_all_separators(): + # stem.replace("-", " ").replace("_", " ").strip() == "" for a stem made + # only of separators -- the final fallback is the literal file name. + assert mod._title_from({}, Path("/x/--__.md")) == "--__.md" + + +# ── _page_item / list_pages / list_projects / _iter_md ───────────────── + + +def test_page_item_reads_frontmatter_fields(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page( + root, + "a/b.md", + "---\n" + "title: B Page\n" + "kind: adr\n" + "domain: cortex\n" + "tags: [x, y]\n" + "status: draft\n" + "date: 2026-01-01\n" + "tended: 2026-02-02\n" + "---\n" + "body\n", + ) + item = mod._page_item(root / "a" / "b.md", root) + assert item == { + "path": "a/b.md", + "title": "B Page", + "kind": "adr", + "domain": "cortex", + "tags": ["x", "y"], + "maturity": "draft", + "created": "2026-01-01", + "updated": "2026-02-02", + } + + +def test_page_item_defaults_kind_to_page_when_absent(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "c.md", "no frontmatter\n") + item = mod._page_item(root / "c.md", root) + assert item["kind"] == "page" + assert item["domain"] == "" + assert item["tags"] == [] + assert item["maturity"] == "" + assert item["created"] == "" + assert item["updated"] == "" + + +def test_page_item_prefers_maturity_over_status_fallback(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "m.md", "---\nmaturity: stable\nstatus: draft\n---\nbody\n") + item = mod._page_item(root / "m.md", root) + assert item["maturity"] == "stable" + + +def test_page_item_prefers_created_over_date_fallback(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "d.md", "---\ncreated: 2026-01-01\ndate: 2020-01-01\n---\nbody\n") + item = mod._page_item(root / "d.md", root) + assert item["created"] == "2026-01-01" + + +def test_page_item_unreadable_file_yields_empty_meta(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + p = _write_page(root, "u.md", "---\ntitle: X\n---\nbody\n") + + def _boom(*a, **k): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _boom) + item = mod._page_item(p, root) + assert item["title"] == "u" # falls back to the filename stem + assert item["kind"] == "page" + + +def test_iter_md_skips_the_bibliography_subtree(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "keep.md", "keep\n") + _write_page(root, "_bibliography/refs.md", "skip me\n") + found = [str(p.relative_to(root)) for p in mod._iter_md(root)] + assert found == ["keep.md"] + + +def test_list_pages_returns_empty_when_root_missing(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "WIKI_ROOT", tmp_path / "does-not-exist") + assert mod.list_pages() == {"pages": []} + + +def test_list_pages_returns_every_page(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "one.md", "---\ntitle: One\n---\nbody\n") + _write_page(root, "two.md", "---\ntitle: Two\n---\nbody\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_pages() + titles = sorted(p["title"] for p in got["pages"]) + assert titles == ["One", "Two"] + + +def test_list_projects_returns_empty_when_root_missing(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "WIKI_ROOT", tmp_path / "does-not-exist") + assert mod.list_projects() == {"projects": []} + + +def test_list_projects_groups_by_domain_with_kind_counts(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "a.md", "---\ndomain: cortex\nkind: adr\n---\nbody\n") + _write_page(root, "b.md", "---\ndomain: cortex\nkind: adr\n---\nbody\n") + _write_page(root, "c.md", "---\ndomain: cortex\nkind: note\n---\nbody\n") + _write_page(root, "d.md", "no frontmatter\n") # domain absent -> _general + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_projects() + by_domain = {p["domain"]: p for p in got["projects"]} + assert by_domain["cortex"]["page_total"] == 3 + assert by_domain["cortex"]["page_counts_by_kind"] == {"adr": 2, "note": 1} + assert by_domain["_general"]["page_total"] == 1 + + +def test_list_projects_sorts_by_descending_page_total(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "big1.md", "---\ndomain: big\n---\nb\n") + _write_page(root, "big2.md", "---\ndomain: big\n---\nb\n") + _write_page(root, "small.md", "---\ndomain: small\n---\nb\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_projects() + domains_in_order = [p["domain"] for p in got["projects"]] + assert domains_in_order == ["big", "small"] + + +# ── list_bibliography / read_bibliography ─────────────────────────────── + + +def test_list_bibliography_empty_when_dir_missing(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.list_bibliography() == {"files": []} + + +def test_list_bibliography_counts_entries_and_reports_size(monkeypatch, tmp_path): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + content = "@book{a,\n title={A}\n}\n@article{b,\n title={B}\n}\n" + (bib_dir / "refs.bib").write_text(content, encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert len(got["files"]) == 1 + entry = got["files"][0] + assert entry["path"] == "_bibliography/refs.bib" + assert entry["entries"] == 2 + assert entry["size"] == len(content.encode("utf-8")) + + +def test_list_bibliography_counts_a_leading_entry_with_no_preceding_newline( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + # The file's very first byte is '@' -- no "\n@" occurs, so the leading + # entry would be undercounted without the lstrip().startswith("@") arm. + (bib_dir / "solo.bib").write_text("@book{a,\n title={A}\n}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 1 + + +def test_read_bibliography_returns_content_and_byte_size(monkeypatch, tmp_path): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + content = "@book{a}\n" + (bib_dir / "refs.bib").write_text(content, encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.read_bibliography("_bibliography/refs.bib") + assert got == { + "path": "_bibliography/refs.bib", + "content": content, + "size": len(content), + } + + +def test_read_bibliography_refuses_a_bib_outside_the_bibliography_subtree( + monkeypatch, tmp_path +): + # _safe_path alone would accept this (it's under WIKI_ROOT and ends in + # .bib) -- the "_bibliography" in p.parts guard is a second, independent + # gate that must reject it too. + root = tmp_path / "wiki" + root.mkdir() + (root / "stray.bib").write_text("@book{a}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.read_bibliography("stray.bib") == {"error": "invalid path"} + + +# ── save_page byte accounting ─────────────────────────────────────────── + + +def test_save_page_reports_utf8_byte_length_not_character_length(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + content = "café" # 4 chars, 5 UTF-8 bytes + got = mod.save_page("page.md", content) + assert got["bytes"] == 5 + assert len(content) == 4 + + +def test_save_page_success_returns_the_exact_response_shape(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.save_page("page.md", "hi\n") + assert got == {"ok": True, "path": "page.md", "bytes": 3} + + +def test_save_page_refuses_a_non_markdown_suffix(monkeypatch, tmp_path): + # _safe_path's suffix gate applies to writes exactly as it does to reads + # -- a non-".md" target must never be created, regardless of containment. + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.save_page("notes.txt", "x") == {"error": "invalid path"} + assert not (root / "notes.txt").exists() + + +def test_save_page_oserror_on_write_reports_the_exact_message(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + + def _boom(self, content, encoding=None): + raise OSError("disk full") + + monkeypatch.setattr(Path, "write_text", _boom) + assert mod.save_page("page.md", "x") == {"error": "disk full"} + + +def test_save_page_round_trips_non_ascii_content_under_a_non_utf8_locale( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + with _c_locale(): + got = mod.save_page("page.md", "café\n") + assert got["ok"] is True, got + assert (root / "page.md").read_bytes().decode("utf-8") == "café\n" + + +# ── _parse_list: the exact strip-charsets, not any charset that happens +# to also remove brackets/quotes ────────────────────────────────────── + + +def test_parse_list_only_strips_the_bracket_characters_not_arbitrary_chars(): + # A value bracketed with 'X' rather than '[' / ']' must survive: proves + # the strip charset is exactly "[]", not a wider set that also matches + # mutmut's "XX[]XX" marker wrapping. + assert mod._parse_list("Xone, twoX") == ["Xone", "twoX"] + + +def test_parse_list_only_strips_quote_characters_not_arbitrary_chars(): + # A token bounded by 'X' (no quotes) must survive per-token stripping + # unchanged -- proves the per-token strip charset is exactly "'\"". + assert mod._parse_list("Xa, Xb") == ["Xa", "Xb"] + + +# ── _page_item: "updated" precedence, and encoding/errors robustness ──── + + +def test_page_item_prefers_updated_over_tended_fallback(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page( + root, "u.md", "---\nupdated: 2026-03-03\ntended: 2020-01-01\n---\nbody\n" + ) + item = mod._page_item(root / "u.md", root) + assert item["updated"] == "2026-03-03" + + +def test_page_item_replaces_invalid_utf8_bytes_instead_of_raising(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + p = root / "bad.md" + p.write_bytes(b"---\ntitle: ok\n---\nbody \xff bytes\n") + item = mod._page_item(p, root) + assert item["title"] == "ok" # must not raise, invalid byte gets replaced + + +def test_page_item_decodes_utf8_regardless_of_process_locale(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + p = root / "accent.md" + p.write_bytes("---\ntitle: café\n---\nbody\n".encode()) + with _c_locale(): + item = mod._page_item(p, root) + assert item["title"] == "café" + + +# ── read_page: exact response shapes, and the OSError arm ──────────────── + + +def test_read_page_not_found_returns_the_exact_error_shape(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.read_page("missing.md") == {"error": "not found"} + + +def test_read_page_success_returns_the_exact_response_shape(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + _write_page(root, "page.md", "---\ntitle: X\n---\nhello\n") + assert mod.read_page("page.md") == { + "path": "page.md", + "meta": {"title": "X"}, + "body": "hello", + } + + +def test_read_page_oserror_on_read_reports_the_exact_message(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + _write_page(root, "page.md", "body\n") + + def _boom(self, encoding=None, errors=None): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _boom) + assert mod.read_page("page.md") == {"error": "permission denied"} + + +def test_read_page_replaces_invalid_utf8_bytes_instead_of_raising( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + (root / "bad.md").write_bytes(b"---\ntitle: ok\n---\nbody \xff bytes\n") + got = mod.read_page("bad.md") + assert "error" not in got + assert got["meta"]["title"] == "ok" + + +def test_read_page_decodes_utf8_regardless_of_process_locale(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + _write_page(root, "accent.md", "---\ntitle: X\n---\ncafé\n") + with _c_locale(): + got = mod.read_page("accent.md") + assert got["body"] == "café" + + +# ── list_bibliography: leading-entry boundary, the else-arm, OSError +# continue-vs-break, and invalid-byte robustness ────────────────────── + + +def test_list_bibliography_counts_a_leading_entry_preceded_only_by_whitespace( + monkeypatch, tmp_path +): + # Leading SPACES (not a preceding "\n@") before the first "@" -- distinguishes + # lstrip() (strips them, sees "@...", counts it) from rstrip() (leaves them, + # the text does not start with "@", would NOT count it). + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "lead.bib").write_text(" @book{a,\n title={A}\n}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 1 + + +def test_list_bibliography_reports_zero_entries_for_a_file_with_no_at_sign( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "empty.bib").write_text("no entries in this file\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 0 + + +def test_list_bibliography_skips_an_unreadable_file_and_still_lists_the_rest( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "a_broken.bib").write_text("@book{a}\n", encoding="utf-8") + (bib_dir / "b_ok.bib").write_text("@book{b}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + + real_read_text = Path.read_text + + def _flaky(self, *a, **k): + if self.name == "a_broken.bib": + raise OSError("unreadable") + return real_read_text(self, *a, **k) + + monkeypatch.setattr(Path, "read_text", _flaky) + got = mod.list_bibliography() + names = {f["path"] for f in got["files"]} + # "continue" (not "break") must let the loop reach b_ok.bib despite + # a_broken.bib raising first (sorted order puts a_broken.bib first). + assert names == {"_bibliography/b_ok.bib"} + + +def test_list_bibliography_replaces_invalid_utf8_bytes_instead_of_raising( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "bad.bib").write_bytes(b"@book{a,\n title={A \xff B}\n}\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 1 # did not raise; counted normally + + +# ── read_bibliography: suffix gate, exact OSError shape, invalid bytes, +# and encoding robustness (content IS surfaced here, unlike list) ───── + + +def test_read_bibliography_refuses_a_non_bib_suffix(monkeypatch, tmp_path): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "notes.txt").write_text("not a bib file", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.read_bibliography("_bibliography/notes.txt") == {"error": "invalid path"} + + +def test_read_bibliography_oserror_on_read_reports_the_exact_message( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "refs.bib").write_text("@book{a}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + + def _boom(self, encoding=None, errors=None): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _boom) + assert mod.read_bibliography("_bibliography/refs.bib") == { + "error": "permission denied" + } + + +def test_read_bibliography_replaces_invalid_utf8_bytes_instead_of_raising( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "bad.bib").write_bytes(b"@book{a,\n title={A \xff B}\n}\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.read_bibliography("_bibliography/bad.bib") + assert "error" not in got + assert "�" in got["content"] + + +def test_read_bibliography_decodes_utf8_regardless_of_process_locale( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "accent.bib").write_bytes("@book{a, title={café}}\n".encode()) + monkeypatch.setattr(mod, "WIKI_ROOT", root) + with _c_locale(): + got = mod.read_bibliography("_bibliography/accent.bib") + assert got["content"] == "@book{a, title={café}}\n"