diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_link_preview_request.py | 98 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_linkpreview.py | 155 |
2 files changed, 253 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_link_preview_request.py b/packages/meshbay-node/tests/test_link_preview_request.py new file mode 100644 index 0000000..d975f91 --- /dev/null +++ b/packages/meshbay-node/tests/test_link_preview_request.py @@ -0,0 +1,98 @@ +""" +`_do_link_preview_request` — routing, the media_cache image round-trip, and +the in-memory result cache. + +The fetch itself (`linkpreview.fetch_preview` / `fetch_image`) is covered by +test_linkpreview.py and stubbed here, so this is purely about what the +handler does with a result: reply shape, storing the OG image under its +blake3 in the thumb store, and not re-fetching a URL it has already seen. +""" + +import blake3 +import pytest +from meshbay_common.protocol import MNP +from meshbay_node import linkpreview +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport import webrtc_server +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +@pytest.fixture(autouse=True) +def _clear_cache(): + webrtc_server._link_preview_cache.clear() + yield + webrtc_server._link_preview_cache.clear() + + +def _session(media_cache): + s = WebRTCPeerSession.__new__(WebRTCPeerSession) + s._ctx = {"media_cache": media_cache} + s.sent = [] + s._send = s.sent.append + return s + + +async def test_reply_carries_the_card_and_caches_the_image(media_cache, monkeypatch): + async def fake_preview(url, **k): + return {"url": url, "title": "Hello", "description": "World", + "site_name": "Example", "image_url": "https://example.com/c.png"} + + async def fake_image(url, **k): + return b"jpeg-bytes" + monkeypatch.setattr(linkpreview, "fetch_preview", fake_preview) + monkeypatch.setattr(linkpreview, "fetch_image", fake_image) + + s = _session(media_cache) + await s._do_link_preview_request({"url": "https://example.com/p"}) + + (resp,) = s.sent + assert resp["type"] == MNP.LINK_PREVIEW_RESP + assert resp["ok"] is True + assert resp["title"] == "Hello" + assert resp["site_name"] == "Example" + want_hash = blake3.blake3(b"jpeg-bytes").hexdigest() + assert resp["image_thumb_hash"] == want_hash + # And the bytes are in the thumb store, servable via the normal file_req path. + assert await media_cache.get_thumb(want_hash) == b"jpeg-bytes" + + +async def test_unfurlable_failure_is_ok_false(media_cache, monkeypatch): + async def none_preview(url, **k): + return None + monkeypatch.setattr(linkpreview, "fetch_preview", none_preview) + + s = _session(media_cache) + await s._do_link_preview_request({"url": "http://169.254.169.254/"}) + + (resp,) = s.sent + assert resp["ok"] is False + assert "image_thumb_hash" not in resp + + +async def test_second_request_for_the_same_url_is_served_from_cache(media_cache, monkeypatch): + calls = {"n": 0} + + async def counting_preview(url, **k): + calls["n"] += 1 + return {"url": url, "title": "Once", "description": None, + "site_name": None, "image_url": None} + monkeypatch.setattr(linkpreview, "fetch_preview", counting_preview) + + s = _session(media_cache) + await s._do_link_preview_request({"url": "https://example.com/a"}) + await s._do_link_preview_request({"url": "https://example.com/a"}) + + assert calls["n"] == 1 + assert len(s.sent) == 2 + assert s.sent[0]["title"] == s.sent[1]["title"] == "Once" + assert all(r["type"] == MNP.LINK_PREVIEW_RESP for r in s.sent) diff --git a/packages/meshbay-node/tests/test_linkpreview.py b/packages/meshbay-node/tests/test_linkpreview.py new file mode 100644 index 0000000..0dd951a --- /dev/null +++ b/packages/meshbay-node/tests/test_linkpreview.py @@ -0,0 +1,155 @@ +""" +`linkpreview` — the SSRF gate and the OpenGraph parse. + +The gate is the part with teeth: the URL is chosen by a *member*, and it +decides an outbound request from the operator's machine. Anything that is not +a public http(s) address must be refused before a socket opens. +""" + +import socket + +import httpx +import pytest +from meshbay_node import linkpreview +from meshbay_node.linkpreview import UnsafeURL, safe_url + +PUBLIC_IP = "93.184.216.34" # example.com, historically + + +@pytest.fixture +def resolves_public(monkeypatch): + """Every hostname resolves to one public address.""" + def fake_getaddrinfo(host, port, *a, **k): + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", + (PUBLIC_IP, port or 80))] + monkeypatch.setattr(linkpreview.socket, "getaddrinfo", fake_getaddrinfo) + + +# ── safe_url ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("url", [ + "http://127.0.0.1/x", + "http://localhost/x", # resolves to loopback on any box + "http://169.254.169.254/latest/meta-data/", # cloud metadata + "http://[::1]/x", + "http://10.1.2.3/x", + "http://192.168.0.1/x", + "http://172.16.0.1/x", + "http://0.0.0.0/x", + "http://[::ffff:127.0.0.1]/x", # v4-mapped loopback + "ftp://example.com/x", + "file:///etc/passwd", + "http://user:pass@example.com/x", + "javascript:alert(1)", + "not a url", +]) +def test_safe_url_refuses(url): + with pytest.raises(UnsafeURL): + safe_url(url) + + +def test_safe_url_accepts_a_public_host(resolves_public): + assert safe_url("https://example.com/some/page") == "https://example.com/some/page" + + +def test_safe_url_refuses_a_host_with_any_private_record(monkeypatch): + def mixed(host, port, *a, **k): + return [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (PUBLIC_IP, port)), + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("127.0.0.1", port)), + ] + monkeypatch.setattr(linkpreview.socket, "getaddrinfo", mixed) + with pytest.raises(UnsafeURL): + safe_url("https://sneaky.example/x") + + +# ── fetch_preview ────────────────────────────────────────────────────────── + +_HTML = """ +<!doctype html><html><head> + <title>Fallback Title</title> + <meta property="og:title" content="The Real Title"> + <meta property="og:description" content="A short summary of the page."> + <meta property="og:site_name" content="Example"> + <meta property="og:image" content="/card.png"> + <meta name="description" content="ignored, og wins"> +</head><body>...body we should not need...</body></html> +""" + + +def _client(handler): + return httpx.AsyncClient(transport=httpx.MockTransport(handler), + timeout=5.0, max_redirects=0) + + +async def test_fetch_preview_reads_opengraph(resolves_public): + def handler(request): + return httpx.Response(200, headers={"content-type": "text/html; charset=utf-8"}, + text=_HTML) + async with _client(handler) as c: + meta = await linkpreview.fetch_preview("https://example.com/article", client=c) + assert meta["title"] == "The Real Title" + assert meta["description"] == "A short summary of the page." + assert meta["site_name"] == "Example" + assert meta["image_url"] == "https://example.com/card.png" # absolutised + + +async def test_fetch_preview_falls_back_to_title_tag(resolves_public): + def handler(request): + return httpx.Response(200, headers={"content-type": "text/html"}, + text="<html><head><title>Just A Title</title></head></html>") + async with _client(handler) as c: + meta = await linkpreview.fetch_preview("https://example.com/", client=c) + assert meta["title"] == "Just A Title" + assert meta["description"] is None + + +async def test_fetch_preview_gives_up_on_non_html(resolves_public): + def handler(request): + return httpx.Response(200, headers={"content-type": "application/pdf"}, + content=b"%PDF-1.4") + async with _client(handler) as c: + assert await linkpreview.fetch_preview("https://example.com/x.pdf", client=c) is None + + +async def test_fetch_preview_gives_up_when_nothing_worth_showing(resolves_public): + def handler(request): + return httpx.Response(200, headers={"content-type": "text/html"}, + text="<html><head></head><body>hi</body></html>") + async with _client(handler) as c: + assert await linkpreview.fetch_preview("https://example.com/", client=c) is None + + +async def test_fetch_preview_revalidates_redirects(monkeypatch): + # First host is public; it 302s to a loopback address. + calls = {"n": 0} + + def resolve(host, port, *a, **k): + ip = PUBLIC_IP if host == "ok.example" else "127.0.0.1" + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, port or 80))] + monkeypatch.setattr(linkpreview.socket, "getaddrinfo", resolve) + + def handler(request): + calls["n"] += 1 + return httpx.Response(302, headers={"location": "http://internal.example/secret"}) + async with _client(handler) as c: + meta = await linkpreview.fetch_preview("https://ok.example/start", client=c) + assert meta is None + assert calls["n"] == 1 # stopped at the redirect, never fetched internal + + +async def test_fetch_image_downscales(resolves_public): + from io import BytesIO + + from PIL import Image + buf = BytesIO() + Image.new("RGB", (2000, 1500), (10, 20, 30)).save(buf, format="PNG") + big_png = buf.getvalue() + + def handler(request): + return httpx.Response(200, headers={"content-type": "image/png"}, content=big_png) + async with _client(handler) as c: + jpeg = await linkpreview.fetch_image("https://example.com/card.png", client=c) + assert jpeg and jpeg[:2] == b"\xff\xd8" # JPEG SOI + with Image.open(BytesIO(jpeg)) as im: + assert max(im.size) <= linkpreview._IMAGE_MAX_DIM |