from datetime import UTC, datetime import pytest from tht.ports.evidence import EvidenceSourceError class Body: def __init__(self, data): self.data, self.closed = data, False def read(self, amount): return self.data[:amount] def close(self): self.closed = True class Client: def __init__(self): self.body = Body(b"hello") def get_paginator(self, name): return self def paginate(self, **kwargs): yield {"Contents": [{"Key": "clinical/a.md", "ETag": '"abc"', "Size": 5, "LastModified": datetime(2026, 1, 1, tzinfo=UTC)}]} def get_object(self, **kwargs): assert kwargs == {"Bucket": "evidence", "Key": "clinical/a.md"} return {"Body": self.body, "ContentLength": 5, "ContentType": "text/markdown", "ETag": '"abc"'} def test_s3_canonical_uri_version_fingerprint_and_closed_body(): from tht.adapters.evidence.s3 import S3EvidenceSource client = Client() source = S3EvidenceSource(bucket="evidence", prefix="clinical/", client=client) item = next(iter(source.discover())) assert item.uri == "s3://evidence/clinical/a.md" assert item.fingerprint.startswith("etag:") assert source.acquire(item).content == b"hello" assert client.body.closed def test_s3_etag_fallback_and_bounds(): from tht.adapters.evidence.s3 import S3EvidenceSource client = Client() with pytest.raises(ValueError): S3EvidenceSource(bucket="evidence", client=client, max_objects=0) def test_s3_rejects_private_or_insecure_endpoint_without_explicit_opt_in(): from tht.adapters.evidence.s3 import S3EvidenceSource with pytest.raises(ValueError, match="trusted"): S3EvidenceSource(bucket="evidence", endpoint_url="https://127.0.0.1:9000", client=Client()) with pytest.raises(ValueError, match="HTTPS"): S3EvidenceSource(bucket="evidence", endpoint_url="http://s3.example.test", client=Client()) source = S3EvidenceSource(bucket="evidence", endpoint_url="http://127.0.0.1:9000", trusted_endpoint=True, allow_private_endpoint=True, allow_insecure_endpoint=True, client=Client()) assert source is not None @pytest.mark.parametrize("bucket", ["UPPER", "bad_bucket", "-start", "end-", "a..b"]) def test_s3_rejects_invalid_bucket_names(bucket): from tht.adapters.evidence.s3 import S3EvidenceSource with pytest.raises(ValueError, match="bucket"): S3EvidenceSource(bucket=bucket, client=Client()) def test_s3_rejects_endpoint_query_path_fragment_and_untrusted_custom_host(): from tht.adapters.evidence.s3 import S3EvidenceSource for endpoint in ("https://s3.example.test/path", "https://s3.example.test/?x=1", "https://s3.example.test/#x"): with pytest.raises(ValueError, match="root"): S3EvidenceSource(bucket="evidence", endpoint_url=endpoint, trusted_endpoint=True, client=Client()) with pytest.raises(ValueError, match="trusted"): S3EvidenceSource(bucket="evidence", endpoint_url="https://s3.example.test", client=Client()) def test_s3_rejects_out_of_prefix_key_and_missing_validator(): from tht.adapters.evidence.s3 import S3EvidenceSource client = Client() client.paginate = lambda **kwargs: iter([{"Contents": [{"Key": "other/a.md", "ETag": '"x"'}]}]) with pytest.raises(EvidenceSourceError): list(S3EvidenceSource(bucket="evidence", prefix="clinical/", client=client).discover()) def test_s3_acquire_rejects_exact_etag_drift_and_closes_body(): from tht.adapters.evidence.s3 import S3EvidenceSource client = Client() source = S3EvidenceSource(bucket="evidence", client=client) item = next(iter(source.discover())) client.get_object = lambda **kwargs: {"Body": client.body, "ContentLength": 5, "ETag": '"changed"'} with pytest.raises(EvidenceSourceError): source.acquire(item) assert client.body.closed client.paginate = lambda **kwargs: iter([{"Contents": [{"Key": "clinical/a.md"}]}]) with pytest.raises(EvidenceSourceError): list(S3EvidenceSource(bucket="evidence", prefix="clinical/", client=client).discover()) def test_s3_size_limit_closes_body(): from tht.adapters.evidence.s3 import S3EvidenceSource client = Client() source = S3EvidenceSource(bucket="evidence", client=client, max_bytes=4) item = next(iter(source.discover())) with pytest.raises(EvidenceSourceError): source.acquire(item) assert client.body.closed def test_s3_config_serialization_masks_credentials(): from tht.config import S3EvidenceSourceConfig config = S3EvidenceSourceConfig(type="s3", bucket="evidence", access_key="access-secret", secret_key="write-secret") assert "access-secret" not in repr(config) assert "write-secret" not in repr(config) def test_s3_config_loads_credentials_from_secret_files(tmp_path): from tht.config import load_config access, secret = tmp_path / "access", tmp_path / "secret" access.write_text("access-value") secret.write_text("secret-value") workspace = tmp_path / "workspace.yaml" workspace.write_text(f""" dwh: type: postgres_direct connection: {{database: d, schema: public, user: u, password: p}} evidence: sources: - type: s3 bucket: evidence access_key_file: {access} secret_key_file: {secret} """) source = load_config(workspace).evidence.sources[0] assert source.access_key.get_secret_value() == "access-value" assert source.secret_key.get_secret_value() == "secret-value"