aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_link_preview_request.py98
-rw-r--r--packages/meshbay-node/tests/test_linkpreview.py155
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