cecc6c0b41
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""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"
|