mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-30 19:52:03 +01:00
Four feeds (public messages, node adverts deduped per node, new nodes, per-community-channel messages), each in RSS 2.0 and Atom, built with stdlib ElementTree. Feeds are hard-pinned to the logged-out view (anonymous visibility, spam always filtered, identity headers ignored) and served at clean public web URLs (/feeds/*) via a shared proxy helper that forwards If-None-Match + X-Forwarded-* and gates on maintenance mode. Responses are Redis-cached (ETag/304, public max-age) and invalidated with messages/adverts/channels mutations. Item/self links always target the public web endpoint; WEB_PUBLIC_URL pins the canonical origin. Includes a FEATURE_FEEDS kill switch, SPA autodiscovery <link> tags, and env/docs updates (v0.21.0 upgrading notes).
147 lines
6.1 KiB
Python
147 lines
6.1 KiB
Python
"""Web-tier tests for the /feeds alias, proxy forwarding, and discovery."""
|
|
|
|
FEED_XML = '<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"></rss>'
|
|
|
|
|
|
class TestFeedsAliasProxy:
|
|
def test_alias_proxies_to_api_and_headers_survive(self, client, mock_http_client):
|
|
"""TR-8 regression: the API's Cache-Control/ETag arrive intact —
|
|
no middleware rewrites responses that already carry cache-control."""
|
|
mock_http_client.set_response(
|
|
"GET",
|
|
"/api/v1/feeds/messages.xml",
|
|
text=FEED_XML,
|
|
headers={
|
|
"content-type": "application/rss+xml; charset=utf-8",
|
|
"cache-control": "public, max-age=300",
|
|
"etag": '"feedetag"',
|
|
},
|
|
)
|
|
response = client.get("/feeds/messages.xml")
|
|
assert response.status_code == 200
|
|
assert mock_http_client.last_request_url == "/api/v1/feeds/messages.xml"
|
|
assert response.text == FEED_XML
|
|
assert response.headers["cache-control"] == "public, max-age=300"
|
|
assert response.headers["etag"] == '"feedetag"'
|
|
assert response.headers["content-type"].startswith("application/rss+xml")
|
|
|
|
def test_alias_forwards_conditional_and_origin_headers(
|
|
self, client, mock_http_client
|
|
):
|
|
"""If-None-Match (end-to-end 304s) and X-Forwarded-* (absolute
|
|
links) must reach the API tier through the alias."""
|
|
mock_http_client.set_response(
|
|
"GET", "/api/v1/feeds/messages.xml", text=FEED_XML
|
|
)
|
|
client.get(
|
|
"/feeds/messages.xml",
|
|
headers={
|
|
"If-None-Match": '"abc123"',
|
|
"X-Forwarded-Host": "hub.example.com",
|
|
"X-Forwarded-Proto": "https",
|
|
},
|
|
)
|
|
forwarded = mock_http_client.last_request_headers or {}
|
|
assert forwarded.get("if-none-match") == '"abc123"'
|
|
assert forwarded.get("x-forwarded-host") == "hub.example.com"
|
|
assert forwarded.get("x-forwarded-proto") == "https"
|
|
|
|
def test_api_proxy_forwards_if_none_match(self, client, mock_http_client):
|
|
"""The main /api proxy forwards conditional headers too (SPA ETag
|
|
revalidation benefit, TR-5)."""
|
|
client.get("/api/v1/messages", headers={"If-None-Match": '"etag1"'})
|
|
forwarded = mock_http_client.last_request_headers or {}
|
|
assert forwarded.get("if-none-match") == '"etag1"'
|
|
|
|
def test_alias_synthesizes_forwarded_headers_without_outer_proxy(
|
|
self, client, mock_http_client
|
|
):
|
|
"""Reported bug: feed links pointed at the internal API service URL.
|
|
|
|
Without an outer reverse proxy the incoming request carries no
|
|
X-Forwarded-Host, so the web tier must synthesize the forwarding
|
|
headers from its own Host header + scheme — the API then builds
|
|
item links against the public origin instead of http://api:8000.
|
|
"""
|
|
client.get("/feeds/messages.xml", headers={"Host": "hub.example.com"})
|
|
forwarded = mock_http_client.last_request_headers or {}
|
|
assert forwarded.get("x-forwarded-host") == "hub.example.com"
|
|
assert forwarded.get("x-forwarded-proto") == "http"
|
|
|
|
def test_alias_passes_through_outer_proxy_forwarded_chain(
|
|
self, client, mock_http_client
|
|
):
|
|
"""Headers set by an outer reverse proxy win over synthesis."""
|
|
client.get(
|
|
"/feeds/messages.xml",
|
|
headers={
|
|
"X-Forwarded-Host": "mesh.example.org",
|
|
"X-Forwarded-Proto": "https",
|
|
},
|
|
)
|
|
forwarded = mock_http_client.last_request_headers or {}
|
|
assert forwarded.get("x-forwarded-host") == "mesh.example.org"
|
|
assert forwarded.get("x-forwarded-proto") == "https"
|
|
|
|
def test_alias_anonymous_access_allowed(self, client, mock_http_client):
|
|
"""Feeds are _OPEN in the endpoint access map — anonymous OK."""
|
|
mock_http_client.set_response("GET", "/api/v1/feeds/nodes.xml", text=FEED_XML)
|
|
response = client.get("/feeds/nodes.xml")
|
|
assert response.status_code == 200
|
|
|
|
def test_alias_404_when_feeds_disabled(
|
|
self, web_app, client, mock_http_client, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
web_app.state,
|
|
"features",
|
|
{**web_app.state.features, "feeds": False},
|
|
)
|
|
response = client.get("/feeds/messages.xml")
|
|
assert response.status_code == 404
|
|
|
|
def test_alias_503_in_maintenance(
|
|
self, web_app, client, mock_http_client, monkeypatch
|
|
):
|
|
"""The alias shares the proxy helper, so maintenance mode gates it."""
|
|
monkeypatch.setattr(web_app.state, "system_maintenance", True)
|
|
response = client.get("/feeds/messages.xml")
|
|
assert response.status_code == 503
|
|
assert response.json()["code"] == "MAINTENANCE"
|
|
|
|
|
|
class TestFeedAutodiscovery:
|
|
def test_spa_head_contains_links(self, client):
|
|
html = client.get("/").text
|
|
assert 'rel="alternate"' in html
|
|
assert 'type="application/rss+xml"' in html
|
|
assert 'type="application/atom+xml"' in html
|
|
assert 'href="/feeds/messages.xml"' in html
|
|
assert 'href="/feeds/adverts.atom"' in html
|
|
assert 'href="/feeds/nodes.xml"' in html
|
|
assert "Test Network — Public Messages (RSS)" in html
|
|
|
|
def test_spa_head_omits_links_when_feeds_disabled(
|
|
self, web_app, client, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
web_app.state,
|
|
"features",
|
|
{**web_app.state.features, "feeds": False},
|
|
)
|
|
html = client.get("/").text
|
|
assert 'rel="alternate"' not in html
|
|
|
|
def test_spa_head_gates_per_feed(self, web_app, client, monkeypatch):
|
|
"""With the messages page disabled, its feed link is omitted but
|
|
the other feeds are still advertised."""
|
|
monkeypatch.setattr(
|
|
web_app.state,
|
|
"features",
|
|
{**web_app.state.features, "messages": False},
|
|
)
|
|
html = client.get("/").text
|
|
assert "/feeds/messages.xml" not in html
|
|
assert "/feeds/messages.atom" not in html
|
|
assert 'href="/feeds/nodes.xml"' in html
|