import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest from tht.adapters.evidence import HttpManifestEvidenceSource from tht.ports.evidence import EvidenceSourceError class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path.startswith("/etag"): self.send_response(200) self.send_header("ETag", '"abc"') self.send_header("Content-Type", "text/markdown") self.end_headers() self.wfile.write(b"hello") elif self.path == "/large": self.send_response(200) self.send_header("Content-Length", "20") self.end_headers() self.wfile.write(b"x" * 20) elif self.path == "/busy": self.send_response(503) self.end_headers() elif self.path == "/missing": self.send_response(404) self.end_headers() elif self.path == "/redirect-private": self.send_response(302) self.send_header("Location", f"http://127.0.0.1:{self.server.server_port}/etag") self.end_headers() else: self.send_response(200) self.send_header("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT") self.end_headers() self.wfile.write(b"fallback") def log_message(self, format, *args): pass @pytest.fixture def server_url(): server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: yield f"http://127.0.0.1:{server.server_port}" finally: server.shutdown() thread.join() def test_http_uses_etag_and_strips_query_from_provenance(server_url): source = HttpManifestEvidenceSource([f"{server_url}/etag?token=secret"]) item = next(iter(source.discover())) assert item.fingerprint.startswith("etag:") assert item.fingerprint != "etag:abc" assert item.uri == f"{server_url}/etag" assert "secret" not in item.model_dump_json() assert source.acquire(item).content == b"hello" def test_http_uses_last_modified_then_content_hash(server_url): modified = next(iter(HttpManifestEvidenceSource([f"{server_url}/modified"]).discover())) assert modified.fingerprint.startswith("last-modified:") class NoValidators(Handler): def do_GET(self): self.send_response(200) self.end_headers() self.wfile.write(b"content") server = ThreadingHTTPServer(("127.0.0.1", 0), NoValidators) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: item = next(iter(HttpManifestEvidenceSource( [f"http://127.0.0.1:{server.server_port}/doc"] ).discover())) assert item.fingerprint.startswith("sha256:") finally: server.shutdown() thread.join() @pytest.mark.parametrize("path,retryable", [("/busy", True), ("/missing", False)]) def test_http_classifies_status_errors(server_url, path, retryable): with pytest.raises(EvidenceSourceError) as caught: list(HttpManifestEvidenceSource([server_url + path]).discover()) assert caught.value.retryable is retryable assert server_url not in str(caught.value) def test_http_rejects_oversize_and_private_redirect(server_url): with pytest.raises(EvidenceSourceError) as large: list(HttpManifestEvidenceSource([server_url + "/large"], max_bytes=10).discover()) assert not large.value.retryable with pytest.raises(EvidenceSourceError) as redirect: list(HttpManifestEvidenceSource([server_url + "/redirect-private"]).discover()) assert not redirect.value.retryable def test_http_rejects_unsupported_manifest_scheme(): with pytest.raises(ValueError, match="http"): HttpManifestEvidenceSource(["file:///tmp/secret"])