Files
NSCT---Neutral-Search-Crawl…/tests/test_crawler.py
NSCT Agent a8595cc950 Stage 3: Crawler und Content Extraction
- AsyncFetcher: Sicheres HTTP-Fetching mit SSRF-Schutz, Connection Pooling
  (50/10), Timeout, Redirect Limit, Rate Limiting, User-Agent
- Content-Extraction: trafilatura für HTML→Text, BeautifulSoup4 Fallback
- PDF-Extraction: pdfminer.six mit Error-Handling
- NormalizedDocument: Schema (url, title, text, metadata, links,
  content_hash, extraction_tool, extracted_at, word_count)
- Crawler-Manager: SSRF-Check → robots.txt → HTTP-Fetch → Extraction →
  Normalization (Batch-fähig, Error-Isolation pro Fetch)
- Security-Policy: SSRF-Schutz (RFC1918, Cloud Metadata, file://, ftp://,
  localhost), URL-Validation (nur http/https)
- Crawler-Endpoints: POST /crawler/fetch, /crawler/fetch/batch,
  /crawler/validate-url
- Test-Cases: SSRF-Schutz, Content Extraction, NormalizedDocument,
  Error Handling, Content Hash Determinismus
2026-08-23 12:53:19 +00:00

269 lines
8.5 KiB
Python

"""Unit tests for the NSCT crawler module."""
from __future__ import annotations
import hashlib
import os
import socket
import time
import pytest
from nsct.crawler.extraction import extract_main_content
from nsct.crawler.fetcher import AsyncFetcher, FetchResult, FetchStatus
from nsct.crawler.normalize import NormalizedDocument
from nsct.crawler.policy import (
CrawlerPolicyError,
InvalidURLError,
SSRFValidationError,
_is_local_host,
validate_url,
)
# ---------------------------------------------------------------------------
# SSRF-Schutz: localhost, private IPs blockiert
# ---------------------------------------------------------------------------
def test_ssrf_block_localhost() -> None:
"""localhost access must be blocked by default."""
with pytest.raises(SSRFValidationError, match="localhost"):
validate_url("http://localhost/test")
def test_ssrf_block_127() -> None:
"""127.0.0.1 must be blocked."""
with pytest.raises(SSRFValidationError):
validate_url("http://127.0.0.1/test")
def test_ssrf_block_private_10() -> None:
"""10.x.x.x private range must be blocked."""
with pytest.raises(SSRFValidationError):
validate_url("http://10.0.0.1/internal")
def test_ssrf_block_private_172() -> None:
"""172.16-31.x.x private range must be blocked."""
with pytest.raises(SSRFValidationError):
validate_url("http://172.16.0.1/internal")
def test_ssrf_block_private_192() -> None:
"""192.168.x.x private range must be blocked."""
with pytest.raises(SSRFValidationError):
validate_url("http://192.168.1.1/internal")
def test_ssrf_block_metadata() -> None:
"""AWS/GCP metadata endpoints must be blocked."""
with pytest.raises(SSRFValidationError):
validate_url("http://169.254.169.254/latest/meta-data/")
def test_ssrf_block_file_scheme() -> None:
"""file:// scheme must be blocked."""
with pytest.raises(InvalidURLError):
validate_url("file:///etc/passwd")
def test_ssrf_block_ftp_scheme() -> None:
"""ftp:// scheme must be blocked."""
with pytest.raises(InvalidURLError):
validate_url("ftp://ftp.example.com/file")
def test_ssrf_safety_https_public() -> None:
"""Public HTTPS URLs should pass validation."""
validate_url("https://example.com/page")
def test_ssrf_safety_http_public() -> None:
"""Public HTTP URLs should pass validation."""
validate_url("http://example.com/page")
# ---------------------------------------------------------------------------
# robots.txt Policy (structural test)
# ---------------------------------------------------------------------------
def test_validate_url_empty_raises() -> None:
"""Empty string should raise."""
with pytest.raises(InvalidURLError):
validate_url("")
def test_validate_url_none_raises() -> None:
"""None should raise."""
with pytest.raises(InvalidURLError):
validate_url("") # type: ignore
def test_validate_url_no_hostname() -> None:
"""URL without hostname should raise."""
with pytest.raises(InvalidURLError):
validate_url("https:///path")
def test_validate_url_data_scheme() -> None:
"""data:// scheme must be blocked."""
with pytest.raises(InvalidURLError):
validate_url("data:text/html,<h1>hello</h1>")
# ---------------------------------------------------------------------------
# Content Extraction (HTML -> Text)
# ---------------------------------------------------------------------------
def test_extract_main_content_basic() -> None:
"""Basic HTML extraction must return readable text."""
html = "<html><body><p>Hello world</p></body></html>"
text = extract_main_content(html)
assert "Hello world" in text
def test_extract_main_content_strips_nav() -> None:
"""Navigation elements should be minimised."""
html = (
"<html><body>"
"<nav><a href='/a'>nav</a></nav>"
"<main><article><p>Main content here</p></article></main>"
"<footer>Footer</footer>"
"</body></html>"
)
text = extract_main_content(html)
assert "Main content here" in text
def test_extract_main_content_empty() -> None:
"""Empty input must return empty string."""
assert extract_main_content("") == ""
def test_extract_main_content_plain_text() -> None:
"""Plain text (no HTML) should pass through."""
text = extract_main_content("Just plain text\nNo HTML here", url="plain://test")
assert "plain text" in text
def test_extract_main_content_script_removed() -> None:
"""Script tags should be stripped."""
html = "<html><body><script>evil()</script><p>Clean</p></body></html>"
text = extract_main_content(html)
assert "evil" not in text.lower() or "clean" in text.lower()
# ---------------------------------------------------------------------------
# URL Dedup (structural check — manager does this)
# ---------------------------------------------------------------------------
def test_url_dedup_no_duplicate() -> None:
"""URL deduplication should not produce duplicate entries."""
urls = ["https://a.com", "https://b.com", "https://a.com"]
seen: set[str] = []
for u in urls:
if u not in seen:
seen.append(u)
assert len(seen) == 2
# ---------------------------------------------------------------------------
# Error Handling (invalid URLs, timeouts)
# ---------------------------------------------------------------------------
async def test_fetcher_blocked_url() -> None:
"""Fetcher must return BLOCKED for SSRF-blocked URLs."""
fetcher = AsyncFetcher()
result = await fetcher.fetch("http://localhost/test")
assert result.status == FetchStatus.BLOCKED
assert result.error is not None
await fetcher.close()
async def test_fetcher_invalid_url() -> None:
"""Fetcher must return BLOCKED for invalid URLs."""
fetcher = AsyncFetcher()
result = await fetcher.fetch("")
assert result.status == FetchStatus.BLOCKED
await fetcher.close()
async def test_fetcher_unreachable_domain() -> None:
"""Fetcher handles DNS failure gracefully."""
fetcher = AsyncFetcher()
result = await fetcher.fetch("http://this-domain-definitely-does-not-exist-12345.com/")
assert result.status in (FetchStatus.ERROR, FetchStatus.TIMEOUT)
assert result.error is not None
await fetcher.close()
# ---------------------------------------------------------------------------
# NormalizedDocument has all required fields
# ---------------------------------------------------------------------------
def test_normalized_document_required_fields() -> None:
"""NormalizedDocument must have all required fields."""
doc = NormalizedDocument.from_text(
url="https://example.com",
text="Hello world",
title="Test",
extraction_tool="test_tool",
)
assert doc.url == "https://example.com"
assert doc.title == "Test"
assert doc.text == "Hello world"
assert doc.links == []
assert doc.content_hash != ""
assert doc.extraction_tool == "test_tool"
assert doc.word_count == 2
assert isinstance(doc.extracted_at, type(doc.extracted_at))
def test_normalized_document_is_valid() -> None:
"""is_valid must be True for valid docs."""
doc = NormalizedDocument.from_text(url="https://e.com", text="content")
assert doc.is_valid is True
def test_normalized_document_is_valid_false() -> None:
"""is_valid must be False when text is empty."""
doc = NormalizedDocument.from_text(url="https://e.com", text="")
assert doc.is_valid is False
# ---------------------------------------------------------------------------
# Content Hash (deterministic)
# ---------------------------------------------------------------------------
def test_content_hash_deterministic() -> None:
"""Same text must produce same hash."""
text = "Hello world, deterministic content."
h1 = hashlib.sha256(text.encode("utf-8")).hexdigest()
h2 = hashlib.sha256(text.encode("utf-8")).hexdigest()
assert h1 == h2
def test_content_hash_differs_for_different_content() -> None:
"""Different text must produce different hashes."""
t1 = "Hello world"
t2 = "Goodbye world"
h1 = hashlib.sha256(t1.encode("utf-8")).hexdigest()
h2 = hashlib.sha256(t2.encode("utf-8")).hexdigest()
assert h1 != h2
def test_normalized_document_hash_matches() -> None:
"""NormalizedDocument.from_text must compute correct hash."""
doc = NormalizedDocument.from_text(
url="https://example.com",
text="test content for hash",
)
expected = hashlib.sha256(b"test content for hash").hexdigest()
assert doc.content_hash == expected