Release v0.20.0: /docs/specs surface + nested flyout nav + session body-list removal
Wave 9 follow-up to roadmap item #30 (Session 0017.0 shipped #30 as v0.19.0; v0.20.0 lands the operator-feedback follow-ups on top). 1. Specs on /docs/specs/<name> (backend docs_specs.py + frontend DocsSpec.jsx + DocsSpecsIndex.jsx). Configured via OHM_DOCS_SPECS; framework default carries OHM's two specs (rfc-app/SPEC.md + flotilla SPEC.md). Runtime fetch from gitea raw with 5-min TTL cache, mirroring docs_sessions.py. 2. Nested flyout nav hierarchy (DocsLayout.jsx). Sessions render as a tree with transcripts nested under each session row (labeled by .N ordinal). New Specs section between User Guide and Sessions. 3. /docs/sessions/<NNNN> body-list removed (DocsSessionIndex.jsx). Body becomes a session-overview card; navigation lives in the left nav. 19 new pytest cases for docs_specs (332 backend total green). Frontend build clean. Sync frontend/package-lock.json version drift (0.15.0 → 0.20.0) alongside the VERSION + package.json bump. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
"""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):
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Same shape as the docs_sessions fixture — `app_with_fake_gitea`
|
||||
monkeypatches `httpx.AsyncClient` for the gitea side, so we
|
||||
construct from the unpatched class directly to avoid the
|
||||
FakeGitea wrapper.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
def install(handler):
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
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"
|
||||
Reference in New Issue
Block a user