from __future__ import annotations from pathlib import Path from app.rag.extract import extract_file, extract_html def _make_txt(d: Path) -> Path: p = d / "doc.txt" p.write_text("El producto pesa 25 kg y tiene garantía de 2 años.", encoding="utf-8") return p def _make_docx(d: Path) -> Path: from docx import Document doc = Document() doc.add_paragraph("La garantía es de dos años.") doc.add_paragraph("Envíos gratis a partir de 199€.") p = d / "doc.docx" doc.save(p) return p def _make_xlsx(d: Path) -> Path: from openpyxl import Workbook wb = Workbook() ws = wb.active ws.append(["Modelo", "X100"]) ws.append(["Peso", "25 kg"]) p = d / "doc.xlsx" wb.save(p) return p def _make_pdf(d: Path) -> Path: from fpdf import FPDF pdf = FPDF() pdf.add_page() pdf.set_font("helvetica", size=12) pdf.cell(0, 10, text="Manual de usuario TOORX") p = d / "doc.pdf" pdf.output(str(p)) return p def test_extract_txt(tmp_path): assert "25 kg" in extract_file(_make_txt(tmp_path)) def test_extract_docx(tmp_path): text = extract_file(_make_docx(tmp_path)) assert "garantía es de dos años" in text assert "199" in text def test_extract_xlsx(tmp_path): text = extract_file(_make_xlsx(tmp_path)) assert "X100" in text assert "25 kg" in text def test_extract_pdf(tmp_path): text = extract_file(_make_pdf(tmp_path)) assert "Manual" in text assert "TOORX" in text def test_extract_html_strips_tags_and_scripts(): html = ( "

Envíos

Gratis a partir de 199€

" "" ) text = extract_html(html) assert "Envíos" in text assert "Gratis a partir de 199€" in text assert "evil" not in text def test_extract_unsupported_raises(tmp_path): p = tmp_path / "thing.xyz" p.write_text("hi", encoding="utf-8") import pytest with pytest.raises(ValueError): extract_file(p) class _Resp: def __init__(self, *, content=b"", text="", status_code=200, content_type="text/html"): self.content = content self.text = text self.status_code = status_code self.headers = {"content-type": content_type} def raise_for_status(self): if self.status_code >= 400: raise RuntimeError(f"http {self.status_code}") def json(self): import json as _json return _json.loads(self.text or self.content or "null") class _FakeClient: """Maps URLs to responses; unknown URLs return 404.""" def __init__(self, pages): self.pages = pages self.requested = [] async def get(self, url, headers=None): self.requested.append(url) return self.pages.get(url, _Resp(status_code=404)) async def test_crawl_follows_internal_links_and_sitemap(): from app.rag.extract import crawl_url base = "https://shop.example" pages = { f"{base}/": _Resp(content=( f'

Inicio

' f'FAQ Envios' f'externo' ).encode()), f"{base}/faq": _Resp(content=b"Garantia de dos anios"), f"{base}/envios": _Resp(content=b"Envio gratis desde 199"), # discovered only via sitemap, not linked from any page f"{base}/oculta": _Resp(content=b"Pagina secreta del sitemap"), f"{base}/sitemap.xml": _Resp( text=f"{base}/oculta", content_type="application/xml", ), } client = _FakeClient(pages) text = await crawl_url(base + "/", client=client) # every internal page is indexed (landing + linked + sitemap-only) assert "Garantia de dos anios" in text assert "Envio gratis desde 199" in text assert "Pagina secreta del sitemap" in text # external domain is never fetched assert "https://otro.com/x" not in client.requested async def test_crawl_respects_max_pages(): from app.rag.extract import crawl_url base = "https://big.example" pages = {f"{base}/sitemap.xml": _Resp(status_code=404)} # landing links to 10 pages; cap at 3 links = " ".join(f'p{i}' for i in range(10)) pages[f"{base}/"] = _Resp(content=f"{links}".encode()) for i in range(10): pages[f"{base}/p{i}"] = _Resp(content=f"page {i}".encode()) client = _FakeClient(pages) await crawl_url(base + "/", max_pages=3, client=client) # the breadth-first CRAWL is capped at 3 pages. The fixed Shopify enrichment # probes (/products.json, /policies/*, /pages/*) are additive and don't count # against max_pages, so exclude them from the crawl-page tally. extras = ("/products.json", "/policies/", "/pages/") page_fetches = [u for u in client.requested if "sitemap" not in u and not any(e in u for e in extras)] assert len(page_fetches) <= 3 # --- crawl-time SSRF guard (for untrusted start URLs, e.g. the public demo) -- def test_assert_public_host_blocks_private_and_allows_public(monkeypatch): import pytest from app.rag import extract # IP literals are classified directly, no DNS — incl. IPv4-mapped IPv6. for bad in ("127.0.0.1", "10.0.0.5", "169.254.169.254", "::1", "::ffff:127.0.0.1", ""): with pytest.raises(extract._BlockedAddress): extract._assert_public_host(bad) # A hostname whose resolution includes a private answer is refused... monkeypatch.setattr(extract, "_resolve_addrs", lambda h: ["93.184.216.34", "10.1.2.3"]) with pytest.raises(extract._BlockedAddress): extract._assert_public_host("rebind.example") # ...while an all-public hostname passes (no exception). monkeypatch.setattr(extract, "_resolve_addrs", lambda h: ["93.184.216.34"]) extract._assert_public_host("good.example") def _redirect_to_private_transport(): import httpx def handler(request): if request.url.host == "shop.test": return httpx.Response(302, headers={"location": "http://169.254.169.254/secret"}) return httpx.Response( 200, text="CLOUD METADATA TOKEN abc123", headers={"content-type": "text/html"} ) return httpx.MockTransport(handler) async def test_crawl_guard_blocks_redirect_into_private_network(monkeypatch): """A public start URL that 302-redirects to the cloud-metadata endpoint must NOT be followed: the internal content never enters the crawl corpus.""" import httpx from app.rag import extract monkeypatch.setattr(extract, "_resolve_addrs", lambda h: ["93.184.216.34"]) client = httpx.AsyncClient(transport=_redirect_to_private_transport(), follow_redirects=True) text, pages = await extract.crawl_site("https://shop.test/", client=client, guard_ssrf=True) await client.aclose() assert "CLOUD METADATA TOKEN" not in text assert pages == [] # the redirect was refused; nothing was fetched async def test_crawl_without_guard_would_leak_redirect_target(monkeypatch): """Control proving the guard above is load-bearing: the SAME redirect WITHOUT guard_ssrf leaks the internal content (so the block test is not vacuous).""" import httpx from app.rag import extract client = httpx.AsyncClient(transport=_redirect_to_private_transport(), follow_redirects=True) text, _pages = await extract.crawl_site("https://shop.test/", client=client) await client.aclose() assert "CLOUD METADATA TOKEN" in text def _blocked_store_with_reader_transport(): """Store returns 200 but EMPTY (datacenter IP soft-blocked); the r.jina.ai reader returns the real rendered text from its own IPs.""" import httpx def handler(request): if request.url.host == "r.jina.ai": return httpx.Response( 200, text="Title: Tienda Frikadas\n\nVendemos juegos de mesa. Envios en 48h. " "Devoluciones en 14 dias.", headers={"content-type": "text/plain"}, ) # the store itself: reachable but serves nothing useful to the crawler return httpx.Response(200, text="", headers={"content-type": "text/html"}) return httpx.MockTransport(handler) async def test_remote_fallback_recovers_blocked_store(): """When a direct crawl comes back empty and remote_fallback=True, crawl_site recovers the store's text via the reader so the demo still trains.""" import httpx from app.rag import extract client = httpx.AsyncClient(transport=_blocked_store_with_reader_transport(), follow_redirects=True) text, pages = await extract.crawl_site("https://shop.test/", client=client, remote_fallback=True) await client.aclose() assert "juegos de mesa" in text assert "Devoluciones" in text assert pages == ["https://shop.test/"] # the home page is recorded async def test_no_remote_fallback_by_default_leaves_empty(): """Without remote_fallback the blocked store yields nothing (no surprise external calls on the normal path).""" import httpx from app.rag import extract client = httpx.AsyncClient(transport=_blocked_store_with_reader_transport(), follow_redirects=True) text, _pages = await extract.crawl_site("https://shop.test/", client=client) await client.aclose() assert text.strip() == "" async def test_crawl_enriches_with_shopify_catalog_and_policies(): """The crawl pulls Shopify's /products.json (catalog) + /policies/* even when the storefront HTML is thin — so products + SHIPPING/returns are always known.""" import json as _json from app.rag.extract import crawl_site base = "https://tienda.example" pages = { f"{base}/sitemap.xml": _Resp(status_code=404), f"{base}/": _Resp(content=b"Bienvenido"), f"{base}/products.json?limit=250": _Resp( text=_json.dumps({"products": [{ "title": "Cinta de correr SRX-100", "product_type": "fitness", "vendor": "Toorx", "body_html": "

Plegable, hasta 12 km/h

", "variants": [{"title": "Default", "price": "499.00"}], }]}), content_type="application/json", ), f"{base}/policies/shipping-policy": _Resp(content=( "Politica de envios: hacemos envios a toda la peninsula " "en 24-48h laborables. Gastos de envio gratis desde 50 euros." ).encode()), } client = _FakeClient(pages) text, urls = await crawl_site(base + "/", max_pages=3, client=client) assert "SRX-100" in text and "499" in text # catalog from products.json assert "24-48h" in text # shipping policy page def test_share_download_url_mappings(): from app.rag.extract import _share_download_url sp = "https://x-my.sharepoint.com/:x:/g/personal/u/IQ123?e=tok" dl, suf = _share_download_url(sp) assert dl.endswith("download=1") and "e=tok" in dl and suf == "xlsx" # keeps the share token word = "https://x-my.sharepoint.com/:w:/g/personal/u/IQ9?e=z" assert _share_download_url(word)[1] == "docx" g = "https://drive.google.com/file/d/ABC123/view?usp=sharing" assert _share_download_url(g)[0] == "https://drive.google.com/uc?export=download&id=ABC123" sheets = "https://docs.google.com/spreadsheets/d/SHID/edit#gid=0" assert _share_download_url(sheets)[0].endswith("/export?format=xlsx") dbx = "https://www.dropbox.com/s/abc/file.pdf?dl=0" assert _share_download_url(dbx)[0].endswith("dl=1") assert _share_download_url("https://example.com/page") is None # not a share link def test_looks_blocked_rejects_walls_keeps_content(): """The remote-reader cascade must NEVER index a CAPTCHA/'access denied' wall as if it were the store's real content (that would feed the bot wrong data).""" from app.rag.extract import _looks_blocked assert _looks_blocked("Access Denied. " * 30) == "" assert _looks_blocked("Just a moment... checking your browser") == "" assert _looks_blocked("Attention Required! Cloudflare") == "" assert _looks_blocked("hi") == "" # too short real = "Cintas de correr y bicicletas de spinning Toorx para uso domestico. " * 6 assert _looks_blocked(real) == real def test_extract_bytes_routes_by_magic_not_extension(monkeypatch): """Parse a file by its CONTENT, not its claimed extension — a legacy .xls (OLE2) downloaded as ".xlsx" (e.g. from SharePoint) must still read.""" from app.rag import extract monkeypatch.setattr(extract, "_extract_xls", lambda d: "XLS") monkeypatch.setattr(extract, "_extract_xlsx", lambda d: "XLSX") monkeypatch.setattr(extract, "_extract_pdf", lambda d: "PDF") monkeypatch.setattr(extract, "_extract_docx", lambda d: "DOCX") assert extract.extract_bytes(b"%PDF-1.4\n...", "xlsx") == "PDF" # magic beats suffix assert extract.extract_bytes(b"PK\x03\x04zip...", "xlsx") == "XLSX" assert extract.extract_bytes(b"PK\x03\x04zip...", "docx") == "DOCX" ole2 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1rest" assert extract.extract_bytes(ole2, "xlsx") == "XLS" # legacy .xls labeled .xlsx assert extract.extract_bytes(b"hola mundo", "txt") == "hola mundo"