"""v0.20.0 — `/api/docs/specs/*` endpoints. Sibling of `test_docs_sessions_vertical.py`. The framework mediates reads of the configured framework-spec URLs (default: rfc-app's own SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the `/docs/specs/*` surface inherits the same chrome as `/docs/user-guide` and `/docs/sessions/*`. This file covers: - The manifest endpoint with the framework default - The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value - Slug validation at the route layer (rejects `..`, `/`, `~`, uppercase, whitespace, path traversal attempts) - Gitea 200 / 404 / 5xx response mapping - Negative caching (404 is cached, not re-fetched within TTL) - Malformed `OHM_DOCS_SPECS` → fallback to the default + a logged warning (asserted by caplog) - A manifest entry that fails per-entry validation (bad slug, missing field) is dropped, with the rest of the list retained Mocking approach: same as docs_sessions — `httpx.MockTransport` substituted into `app.docs_specs.httpx.AsyncClient` via a fixture. """ from __future__ import annotations import json import logging import httpx import pytest from fastapi.testclient import TestClient from app import docs_specs # Reuse the proven app-construction fixtures from the proposal vertical # (same shape every test file in this repo uses). from test_propose_vertical import ( # noqa: F401 app_with_fake_gitea, tmp_env, ) # --------------------------------------------------------------------------- # Test scaffolding # --------------------------------------------------------------------------- class _UpstreamHandler: """Records every URL the docs_specs module fetched and returns canned responses keyed by URL substring. Lets the test assert on call count (for cache verification) without booting a full upstream simulator. `calls` tracks only URLs that hit a host configured in the spec manifest under test — so unrelated httpx clients (gitea-side fixtures, etc.) don't inflate the count we use for cache-hit assertions. We marker-match on substrings the manifest carries. """ def __init__( self, responses: dict[str, tuple[int, str]], host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"), ): self.responses = responses self.host_markers = host_markers self.calls: list[str] = [] def __call__(self, request: httpx.Request) -> httpx.Response: url = str(request.url) if any(m in url for m in self.host_markers): self.calls.append(url) for key, (status, body) in self.responses.items(): if key in url: return httpx.Response(status, text=body) # Default: 404. Lets tests skip declaring "the rest is 404". return httpx.Response(404, text="not found") @pytest.fixture def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811 """Provide a hook the test can call to install a MockTransport. M3 note: lifespan now calls `refresh_registry` which hits FakeGitea via the gitea transport. Since `httpx` is a module singleton, installing the docs transport would clobber the FakeGitea mock already installed by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API requests (to `http://gitea.test/`) are delegated to FakeGitea; all other requests go to the test-specific handler. """ from httpx._client import AsyncClient as RealAsyncClient _fake = app_with_fake_gitea[1] def install(handler): def composite(request: httpx.Request) -> httpx.Response: if "gitea.test" in str(request.url): return _fake.handle(request) return handler(request) def patched(*args, **kwargs): kwargs["transport"] = httpx.MockTransport(composite) return RealAsyncClient(*args, **kwargs) monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched) return handler yield install @pytest.fixture def app(app_with_fake_gitea): """Reset the docs-specs cache so cross-test state can't leak.""" docs_specs.reset_cache() fastapi_app, _fake = app_with_fake_gitea return fastapi_app # --------------------------------------------------------------------------- # Manifest endpoint # --------------------------------------------------------------------------- def test_manifest_default(app, monkeypatch): """With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the framework default (rfc-app + flotilla). """ monkeypatch.delenv("OHM_DOCS_SPECS", raising=False) with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") assert r.status_code == 200, r.text payload = r.json() assert "specs" in payload names = [s["name"] for s in payload["specs"]] assert names == ["rfc-app", "flotilla"] # The default URLs point at the OHM-canonical gitea raw paths. assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"]) def test_manifest_overridden(app, monkeypatch): """A deployment overriding `OHM_DOCS_SPECS` gets its custom list. The manifest is parsed per-request from the env var (no startup binding) so a runtime overlay change is visible without a restart — same shape as the docs_sessions env knobs. """ custom = json.dumps( [ { "name": "custom-spec", "title": "Custom Spec", "url": "https://specs.example.org/CUSTOM.md", } ] ) monkeypatch.setenv("OHM_DOCS_SPECS", custom) with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") assert r.status_code == 200, r.text payload = r.json() assert payload == { "specs": [ { "name": "custom-spec", "title": "Custom Spec", "url": "https://specs.example.org/CUSTOM.md", } ] } def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog): """A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the endpoint falls back to the framework default. Startup is unaffected — the deployment continues to render the spec surface rather than crashing on the typo. """ monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json") with caplog.at_level(logging.WARNING, logger="app.docs_specs"): with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") assert r.status_code == 200, r.text payload = r.json() names = [s["name"] for s in payload["specs"]] assert names == ["rfc-app", "flotilla"] assert any( "OHM_DOCS_SPECS is not valid JSON" in rec.message for rec in caplog.records ), f"expected a logged warning; got {[r.message for r in caplog.records]}" def test_manifest_non_list_falls_back(app, monkeypatch, caplog): """`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any non-list value) falls back to the default + logs a warning. """ monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"})) with caplog.at_level(logging.WARNING, logger="app.docs_specs"): with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") assert r.status_code == 200, r.text names = [s["name"] for s in r.json()["specs"]] assert names == ["rfc-app", "flotilla"] assert any( "must be a JSON array" in rec.message for rec in caplog.records ) def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog): """Per-entry validation: an entry with a bad slug or missing field is dropped; valid entries in the same list are retained. """ custom = json.dumps( [ {"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space {"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal {"name": "missing-url", "title": "Missing URL"}, # no url { "name": "good-spec", "title": "Good", "url": "https://specs.example.org/GOOD.md", }, ] ) monkeypatch.setenv("OHM_DOCS_SPECS", custom) with caplog.at_level(logging.WARNING, logger="app.docs_specs"): with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") assert r.status_code == 200, r.text names = [s["name"] for s in r.json()["specs"]] assert names == ["good-spec"] # Three drop warnings (one per bad entry). drops = [r for r in caplog.records if "failed validation" in r.message] assert len(drops) == 3 def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog): """If every entry is dropped, the framework default applies (the surface never goes empty due to a bad overlay). """ monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]), ) with caplog.at_level(logging.WARNING, logger="app.docs_specs"): with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") assert r.status_code == 200, r.text names = [s["name"] for s in r.json()["specs"]] assert names == ["rfc-app", "flotilla"] assert any( "yielded no valid entries" in rec.message for rec in caplog.records ) def test_manifest_drops_duplicate_names(app, monkeypatch, caplog): """A duplicate `name` is dropped (the first occurrence wins). The route layer's `/api/docs/specs/{name}` path lookup is by name, so duplicates would otherwise be ambiguous. """ custom = json.dumps( [ {"name": "x", "title": "First", "url": "https://specs.example/1"}, {"name": "x", "title": "Second", "url": "https://specs.example/2"}, ] ) monkeypatch.setenv("OHM_DOCS_SPECS", custom) with caplog.at_level(logging.WARNING, logger="app.docs_specs"): with TestClient(app) as client: r = client.get("/api/docs/specs/manifest") payload = r.json() assert [s["title"] for s in payload["specs"]] == ["First"] assert any("duplicate name" in rec.message for rec in caplog.records) # --------------------------------------------------------------------------- # Spec endpoint — happy + error paths # --------------------------------------------------------------------------- def test_spec_happy_path(app, patched_httpx, monkeypatch): monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps( [ { "name": "rfc-app", "title": "rfc-app SPEC", "url": "https://specs.example.org/rfc-app/SPEC.md", } ] ), ) body = "# rfc-app SPEC\n\nSection 1...\n" patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)})) with TestClient(app) as client: r = client.get("/api/docs/specs/rfc-app") assert r.status_code == 200, r.text assert "text/markdown" in r.headers["content-type"] assert r.text == body def test_spec_upstream_404(app, patched_httpx, monkeypatch): monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps( [ { "name": "missing-spec", "title": "Missing", "url": "https://specs.example.org/missing.md", } ] ), ) patched_httpx(_UpstreamHandler({})) # everything 404s with TestClient(app) as client: r = client.get("/api/docs/specs/missing-spec") assert r.status_code == 404, r.text def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch): monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps( [ { "name": "broken-spec", "title": "Broken", "url": "https://specs.example.org/broken.md", } ] ), ) patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")})) with TestClient(app) as client: r = client.get("/api/docs/specs/broken-spec") assert r.status_code == 502, r.text body = r.json() assert body["detail"]["error"] == "specs fetch failed" def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch): """A name that doesn't appear in the manifest returns 404 without touching the network. The handler treats "no such configured spec" and "upstream 404" as the same outcome — both render the same "spec not found" empty state on the frontend. """ monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps( [ { "name": "rfc-app", "title": "rfc-app", "url": "https://specs.example.org/x.md", } ] ), ) handler = _UpstreamHandler({}) patched_httpx(handler) with TestClient(app) as client: r = client.get("/api/docs/specs/does-not-exist") assert r.status_code == 404, r.text assert handler.calls == [], "unknown-name lookup must not hit the network" # --------------------------------------------------------------------------- # Spec endpoint — slug validation # --------------------------------------------------------------------------- @pytest.mark.parametrize( "raw_name", [ "UPPER", # uppercase "spaces here", # whitespace (post-decoding) "with~tilde", # tilde "with.dot", # dot "with_under", # underscore (not allowed by [a-z0-9-]+) ], ) def test_spec_rejects_invalid_name(app, patched_httpx, raw_name): """Names that don't match `^[a-z0-9-]+$` are rejected with 400 at the route layer before any network or cache work. Note: `..` is intentionally not in this list because the URL- parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs` before the handler is reached — the path-traversal protection is therefore framework-level (httpx/urllib's path normalizer) rather than route-layer. The slug-validation guard still rejects any `..` that *would* reach the handler (e.g. via an env-configured manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid` for that path. """ handler = _UpstreamHandler({}) patched_httpx(handler) with TestClient(app) as client: from urllib.parse import quote r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}") assert r.status_code == 400, r.text assert handler.calls == [], "rejected name must not hit the network" def test_spec_rejects_slash_in_name(app, patched_httpx): """A literal `/` in the path can't make it through the path parameter — FastAPI routes it as a separate segment. The check here is that the request never reaches an upstream fetch. """ handler = _UpstreamHandler({}) patched_httpx(handler) with TestClient(app) as client: # `/api/docs/specs/sub/path` — the second segment makes this # not match the `/{name}` route at all; FastAPI returns 404. r = client.get("/api/docs/specs/sub/path") assert r.status_code == 404, r.text assert handler.calls == [], "non-matching path must not hit the network" # --------------------------------------------------------------------------- # Cache behavior # --------------------------------------------------------------------------- def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch): monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300") monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps( [ { "name": "rfc-app", "title": "rfc-app", "url": "https://specs.example.org/rfc-app/SPEC.md", } ] ), ) handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")}) patched_httpx(handler) with TestClient(app) as client: r1 = client.get("/api/docs/specs/rfc-app") r2 = client.get("/api/docs/specs/rfc-app") assert r1.status_code == 200 assert r2.status_code == 200 assert len(handler.calls) == 1, ( f"expected one upstream call, got {handler.calls}" ) def test_spec_404_is_cached(app, patched_httpx, monkeypatch): """Negative caching: a 404 result is cached at the content TTL so a deployment with a misconfigured spec URL doesn't hammer the upstream on every navigation. """ monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300") monkeypatch.setenv( "OHM_DOCS_SPECS", json.dumps( [ { "name": "missing-spec", "title": "Missing", "url": "https://specs.example.org/missing.md", } ] ), ) handler = _UpstreamHandler({}) # everything 404s patched_httpx(handler) with TestClient(app) as client: r1 = client.get("/api/docs/specs/missing-spec") r2 = client.get("/api/docs/specs/missing-spec") assert r1.status_code == 404 assert r2.status_code == 404 assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"