181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from defusedxml import ElementTree as SafeET
|
|
from fastapi import HTTPException
|
|
|
|
from govoplan_connectors.backend.feeds import (
|
|
FEED_PRIVATE_PUBLISH_SCOPE,
|
|
FEED_PUBLISH_SCOPE,
|
|
ConnectorFeedProvider,
|
|
feed_rows,
|
|
)
|
|
from govoplan_connectors.backend.router import api_render_feed
|
|
from govoplan_connectors.backend.schemas import FeedRenderPayload
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.access import PrincipalRef
|
|
from govoplan_core.core.feeds import (
|
|
FeedCapabilityError,
|
|
FeedEntry,
|
|
FeedRenderRequest,
|
|
)
|
|
from govoplan_core.security.http_fetch import HttpFetchResponse
|
|
|
|
|
|
RSS = b"""<?xml version="1.0"?>
|
|
<rss version="2.0"><channel>
|
|
<title>Decisions</title><link>https://example.test/</link>
|
|
<description>Published decisions</description>
|
|
<item><guid>decision-1</guid><title>Decision one</title>
|
|
<link>https://example.test/1</link>
|
|
<pubDate>Fri, 31 Jul 2026 10:00:00 GMT</pubDate>
|
|
<category>planning</category>
|
|
</item>
|
|
</channel></rss>"""
|
|
|
|
ATOM = b"""<?xml version="1.0"?>
|
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
<id>https://example.test/feed</id><title>Updates</title>
|
|
<updated>2026-07-31T10:00:00Z</updated>
|
|
<link href="https://example.test/" />
|
|
<entry><id>update-1</id><title>Update one</title>
|
|
<updated>2026-07-31T10:00:00Z</updated>
|
|
<link href="https://example.test/update-1" />
|
|
</entry>
|
|
</feed>"""
|
|
|
|
|
|
class ConnectorFeedProviderTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.provider = ConnectorFeedProvider()
|
|
|
|
def test_rss_and_atom_are_normalized_to_tabular_entries(self) -> None:
|
|
rss = self.provider.parse(RSS, source_url="https://example.test/rss")
|
|
atom = self.provider.parse(ATOM, source_url="https://example.test/atom")
|
|
|
|
self.assertEqual("rss", rss.format)
|
|
self.assertEqual("decision-1", rss.entries[0].id)
|
|
self.assertEqual("atom", atom.format)
|
|
self.assertEqual("https://example.test/update-1", atom.entries[0].url)
|
|
self.assertEqual("decision-1", feed_rows(rss)[0]["id"])
|
|
self.assertEqual(64, len(rss.sha256))
|
|
|
|
def test_fetch_records_transport_freshness_and_provenance(self) -> None:
|
|
with patch(
|
|
"govoplan_connectors.backend.feeds.fetch_http",
|
|
return_value=HttpFetchResponse(
|
|
status=200,
|
|
headers={
|
|
"Content-Type": "application/rss+xml",
|
|
"Cache-Control": "public, max-age=600",
|
|
"ETag": '"feed-1"',
|
|
},
|
|
body=RSS,
|
|
),
|
|
):
|
|
document = self.provider.fetch("https://example.test/rss")
|
|
|
|
self.assertEqual('"feed-1"', document.etag)
|
|
self.assertIsNotNone(document.acquired_at)
|
|
self.assertEqual(600, int((document.fresh_until - document.acquired_at).total_seconds()))
|
|
self.assertEqual(len(RSS), document.metadata["byte_count"])
|
|
|
|
def test_render_filters_entries_by_explicit_visibility(self) -> None:
|
|
request = FeedRenderRequest(
|
|
format="atom",
|
|
title="Public updates",
|
|
feed_url="https://example.test/feed.atom",
|
|
home_url="https://example.test/",
|
|
entries=(
|
|
FeedEntry(id="public-1", title="Public", visibility="public"),
|
|
FeedEntry(id="tenant-1", title="Tenant", visibility="tenant"),
|
|
),
|
|
allowed_visibilities=frozenset({"public"}),
|
|
)
|
|
result = self.provider.render(request)
|
|
root = SafeET.fromstring(result.body)
|
|
|
|
self.assertEqual(1, result.included_entries)
|
|
self.assertEqual(1, result.excluded_entries)
|
|
self.assertIn(b"Public", result.body)
|
|
self.assertNotIn(b"Tenant", result.body)
|
|
self.assertTrue(root.tag.endswith("feed"))
|
|
|
|
def test_unsafe_xml_is_rejected(self) -> None:
|
|
payload = b'<!DOCTYPE x [<!ENTITY y SYSTEM "file:///etc/passwd">]><rss>&y;</rss>'
|
|
with self.assertRaisesRegex(FeedCapabilityError, "not safe or valid"):
|
|
self.provider.parse(payload, source_url="https://example.test/rss")
|
|
|
|
def test_render_api_derives_visibility_from_audience_and_permissions(self) -> None:
|
|
entries = [
|
|
{
|
|
"id": visibility,
|
|
"title": visibility.title(),
|
|
"visibility": visibility,
|
|
"source_kind": source_kind,
|
|
"source_module": source_module,
|
|
"source_ref": f"{source_kind}:{visibility}",
|
|
"source_revision": "7",
|
|
}
|
|
for visibility, source_kind, source_module in (
|
|
("public", "publication", "docs"),
|
|
("tenant", "case", "cases"),
|
|
("private", "report", "reporting"),
|
|
)
|
|
]
|
|
public_payload = FeedRenderPayload(
|
|
format="rss",
|
|
title="Selected GovOPlaN updates",
|
|
feed_url="https://example.test/feed.xml",
|
|
home_url="https://example.test/",
|
|
audience="public",
|
|
entries=entries,
|
|
)
|
|
public = api_render_feed(
|
|
public_payload,
|
|
principal=_principal(FEED_PUBLISH_SCOPE),
|
|
)
|
|
|
|
self.assertEqual("public", public.headers["x-govoplan-feed-audience"])
|
|
self.assertEqual("1", public.headers["x-govoplan-feed-included"])
|
|
self.assertEqual("2", public.headers["x-govoplan-feed-excluded"])
|
|
self.assertIn(b"Public", public.body)
|
|
self.assertNotIn(b"Tenant", public.body)
|
|
|
|
restricted_payload = public_payload.model_copy(update={"audience": "private"})
|
|
with self.assertRaises(HTTPException) as denied:
|
|
api_render_feed(
|
|
restricted_payload,
|
|
principal=_principal(FEED_PUBLISH_SCOPE),
|
|
)
|
|
self.assertEqual(403, denied.exception.status_code)
|
|
|
|
restricted = api_render_feed(
|
|
restricted_payload,
|
|
principal=_principal(
|
|
FEED_PUBLISH_SCOPE,
|
|
FEED_PRIVATE_PUBLISH_SCOPE,
|
|
),
|
|
)
|
|
self.assertEqual("3", restricted.headers["x-govoplan-feed-included"])
|
|
self.assertIn(b"Private", restricted.body)
|
|
|
|
|
|
def _principal(*scopes: str) -> ApiPrincipal:
|
|
return ApiPrincipal(
|
|
principal=PrincipalRef(
|
|
account_id="account-1",
|
|
membership_id="membership-1",
|
|
tenant_id="tenant-1",
|
|
scopes=frozenset(scopes),
|
|
),
|
|
account=object(),
|
|
user=object(),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|