""" Comprehensive tests for the C2PA bridge. Tests are split into three sections: 1. vendor_assertions.py -- pure dict I/O, no c2pa-python required. 2. cert.py -- X.509 certificate generation, no c2pa-python required. 3. export.py -- GPS downsampling and assertion building; the full Builder.sign() path is skipped when c2pa-python is absent. c2pa-python-dependent tests are guarded with pytest.importorskip("c2pa") so the suite stays green in environments that only have the base [dev] extras. """ from __future__ import annotations import datetime from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest from cryptography import x509 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.x509.oid import NameOID from fieldwitness.attest.models import ( AttestationRecord, CaptureDevice, CaptureMetadata, GeoLocation, ImageHashes, ) from fieldwitness.c2pa_bridge.cert import ( _CERT_VALIDITY_YEARS, _generate_cert, get_or_create_c2pa_cert, ) from fieldwitness.c2pa_bridge.export import ( _build_exif_assertion, _downsample_gps, ) from fieldwitness.c2pa_bridge.vendor_assertions import ( DEFAULT_PHASH_THRESHOLD, SCHEMA_VERSION, build_attestation_id_assertion, build_chain_record_assertion, build_perceptual_hashes_assertion, parse_attestation_id_assertion, parse_chain_record_assertion, parse_perceptual_hashes_assertion, ) from fieldwitness.config import FieldWitnessConfig from fieldwitness.federation.models import AttestationChainRecord, EntropyWitnesses # ── Shared helpers ──────────────────────────────────────────────────────────── def _make_hashes( sha256: str = "a" * 64, phash: str = "abc123", dhash: str = "def456", ahash: str | None = "ghi789", colorhash: str | None = "jkl012", crop_resistant: str | None = "mno345", ) -> ImageHashes: return ImageHashes( sha256=sha256, phash=phash, dhash=dhash, ahash=ahash, colorhash=colorhash, crop_resistant=crop_resistant, ) def _make_record( hashes: ImageHashes | None = None, fingerprint: str = "fp" * 16, metadata: dict[str, Any] | None = None, content_type: str = "image", ) -> AttestationRecord: if hashes is None: hashes = _make_hashes() key = Ed25519PrivateKey.generate() sig = key.sign(b"dummy") return AttestationRecord( image_hashes=hashes, signature=sig, attestor_fingerprint=fingerprint, timestamp=datetime.datetime(2024, 3, 15, 12, 0, 0, tzinfo=datetime.UTC), metadata=metadata or {}, content_type=content_type, ) def _make_chain_record( chain_index: int = 0, with_entropy: bool = True, ) -> AttestationChainRecord: ew = ( EntropyWitnesses( sys_uptime=3600.5, fs_snapshot=b"\xab" * 16, proc_entropy=42, boot_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", ) if with_entropy else None ) return AttestationChainRecord( version=1, record_id=bytes(range(16)), chain_index=chain_index, prev_hash=b"\x00" * 32, content_hash=b"\xff" * 32, content_type="soosef/attestation-v1", claimed_ts=1710504000_000000, entropy_witnesses=ew, signer_pubkey=b"\x11" * 32, signature=b"\x22" * 64, ) # ═══════════════════════════════════════════════════════════════════════════════ # Section 1: vendor_assertions.py # ═══════════════════════════════════════════════════════════════════════════════ class TestBuildPerceptualHashesAssertion: def test_all_hash_types_present(self) -> None: hashes = _make_hashes() payload = build_perceptual_hashes_assertion(hashes) assert payload["schema_version"] == SCHEMA_VERSION assert payload["sha256"] == hashes.sha256 assert payload["phash"] == hashes.phash assert payload["dhash"] == hashes.dhash assert payload["ahash"] == hashes.ahash assert payload["colorhash"] == hashes.colorhash assert payload["crop_resistant"] == hashes.crop_resistant assert payload["threshold"] == DEFAULT_PHASH_THRESHOLD def test_missing_optional_hashes_omitted(self) -> None: hashes = ImageHashes(sha256="b" * 64, phash="", dhash="") payload = build_perceptual_hashes_assertion(hashes) assert "phash" not in payload assert "dhash" not in payload assert "ahash" not in payload assert "colorhash" not in payload assert "crop_resistant" not in payload assert payload["sha256"] == "b" * 64 def test_custom_threshold(self) -> None: hashes = _make_hashes() payload = build_perceptual_hashes_assertion(hashes, threshold=5) assert payload["threshold"] == 5 def test_schema_version_is_v1(self) -> None: payload = build_perceptual_hashes_assertion(_make_hashes()) assert payload["schema_version"] == "v1" class TestParsePerceptualHashesAssertion: def test_round_trip_all_hashes(self) -> None: hashes = _make_hashes() original = build_perceptual_hashes_assertion(hashes) parsed = parse_perceptual_hashes_assertion(original) assert parsed["schema_version"] == "v1" assert parsed["sha256"] == hashes.sha256 assert parsed["phash"] == hashes.phash assert parsed["dhash"] == hashes.dhash assert parsed["ahash"] == hashes.ahash assert parsed["colorhash"] == hashes.colorhash assert parsed["crop_resistant"] == hashes.crop_resistant def test_round_trip_missing_optional_hashes(self) -> None: hashes = ImageHashes(sha256="c" * 64) original = build_perceptual_hashes_assertion(hashes) parsed = parse_perceptual_hashes_assertion(original) assert parsed["sha256"] == "c" * 64 assert "phash" not in parsed assert "ahash" not in parsed def test_missing_schema_version_raises(self) -> None: with pytest.raises(ValueError, match="schema_version"): parse_perceptual_hashes_assertion({"sha256": "abc"}) def test_unknown_schema_version_raises(self) -> None: with pytest.raises(ValueError, match="unrecognised schema_version"): parse_perceptual_hashes_assertion({"schema_version": "v99", "sha256": "abc"}) def test_threshold_defaults_when_absent(self) -> None: parsed = parse_perceptual_hashes_assertion({"schema_version": "v1", "sha256": "abc"}) assert parsed["threshold"] == DEFAULT_PHASH_THRESHOLD class TestBuildChainRecordAssertion: def test_basic_fields_present(self) -> None: cr = _make_chain_record() payload = build_chain_record_assertion(cr) assert payload["schema_version"] == "v1" assert payload["chain_index"] == cr.chain_index assert payload["record_id"] == cr.record_id.hex() assert payload["content_hash"] == cr.content_hash.hex() assert payload["content_type"] == cr.content_type assert payload["claimed_ts_us"] == cr.claimed_ts assert payload["prev_hash"] == cr.prev_hash.hex() assert payload["signer_pubkey"] == cr.signer_pubkey.hex() assert payload["signature"] == cr.signature.hex() assert payload["version"] == cr.version def test_entropy_witnesses_embedded(self) -> None: cr = _make_chain_record(with_entropy=True) payload = build_chain_record_assertion(cr) ew = payload["entropy_witnesses"] assert ew["sys_uptime"] == cr.entropy_witnesses.sys_uptime # type: ignore[union-attr] assert ew["fs_snapshot"] == cr.entropy_witnesses.fs_snapshot.hex() # type: ignore[union-attr] assert ew["proc_entropy"] == cr.entropy_witnesses.proc_entropy # type: ignore[union-attr] assert ew["boot_id"] == cr.entropy_witnesses.boot_id # type: ignore[union-attr] def test_entropy_witnesses_absent_when_none(self) -> None: cr = _make_chain_record(with_entropy=False) payload = build_chain_record_assertion(cr) assert "entropy_witnesses" not in payload def test_inclusion_proof_embedded(self) -> None: cr = _make_chain_record() proof = MagicMock() proof.leaf_hash = "aabbcc" proof.leaf_index = 7 proof.tree_size = 100 proof.proof_hashes = ["d1", "e2"] proof.root_hash = "ff00ff" payload = build_chain_record_assertion(cr, inclusion_proof=proof) ip = payload["inclusion_proof"] assert ip["leaf_hash"] == "aabbcc" assert ip["leaf_index"] == 7 assert ip["tree_size"] == 100 assert ip["proof_hashes"] == ["d1", "e2"] assert ip["root_hash"] == "ff00ff" def test_no_inclusion_proof_by_default(self) -> None: cr = _make_chain_record() payload = build_chain_record_assertion(cr) assert "inclusion_proof" not in payload def test_schema_version_is_v1(self) -> None: payload = build_chain_record_assertion(_make_chain_record()) assert payload["schema_version"] == "v1" class TestParseChainRecordAssertion: def test_round_trip_with_entropy(self) -> None: cr = _make_chain_record(with_entropy=True) original = build_chain_record_assertion(cr) parsed = parse_chain_record_assertion(original) assert parsed["schema_version"] == "v1" assert parsed["chain_index"] == cr.chain_index assert parsed["record_id"] == cr.record_id.hex() assert parsed["content_hash"] == cr.content_hash.hex() assert "entropy_witnesses" in parsed def test_round_trip_without_entropy(self) -> None: cr = _make_chain_record(with_entropy=False) original = build_chain_record_assertion(cr) parsed = parse_chain_record_assertion(original) assert parsed["schema_version"] == "v1" assert "entropy_witnesses" not in parsed def test_round_trip_with_inclusion_proof(self) -> None: cr = _make_chain_record() proof = MagicMock() proof.leaf_hash = "aabbcc" proof.leaf_index = 3 proof.tree_size = 50 proof.proof_hashes = ["x1"] proof.root_hash = "rootroot" original = build_chain_record_assertion(cr, inclusion_proof=proof) parsed = parse_chain_record_assertion(original) assert "inclusion_proof" in parsed assert parsed["inclusion_proof"]["leaf_index"] == 3 def test_missing_schema_version_raises(self) -> None: with pytest.raises(ValueError, match="schema_version"): parse_chain_record_assertion({"chain_index": 0}) def test_unknown_schema_version_raises(self) -> None: with pytest.raises(ValueError, match="unrecognised schema_version"): parse_chain_record_assertion({"schema_version": "v2", "chain_index": 0}) class TestBuildAttestationIdAssertion: def test_basic_fields(self) -> None: payload = build_attestation_id_assertion( record_id="abc123", attestor_fingerprint="fp001", content_type="image", ) assert payload["schema_version"] == "v1" assert payload["record_id"] == "abc123" assert payload["attestor_fingerprint"] == "fp001" assert payload["content_type"] == "image" def test_default_content_type(self) -> None: payload = build_attestation_id_assertion( record_id="xyz", attestor_fingerprint="fp002", ) assert payload["content_type"] == "image" def test_schema_version_is_v1(self) -> None: payload = build_attestation_id_assertion("r", "f") assert payload["schema_version"] == "v1" def test_document_content_type(self) -> None: payload = build_attestation_id_assertion("r", "f", content_type="document") assert payload["content_type"] == "document" class TestParseAttestationIdAssertion: def test_round_trip(self) -> None: original = build_attestation_id_assertion("rec01", "finger01", "audio") parsed = parse_attestation_id_assertion(original) assert parsed["schema_version"] == "v1" assert parsed["record_id"] == "rec01" assert parsed["attestor_fingerprint"] == "finger01" assert parsed["content_type"] == "audio" def test_missing_schema_version_raises(self) -> None: with pytest.raises(ValueError, match="schema_version"): parse_attestation_id_assertion({"record_id": "x"}) def test_unknown_schema_version_raises(self) -> None: with pytest.raises(ValueError, match="unrecognised schema_version"): parse_attestation_id_assertion({"schema_version": "beta", "record_id": "x"}) def test_missing_optional_fields_use_defaults(self) -> None: parsed = parse_attestation_id_assertion({"schema_version": "v1"}) assert parsed["record_id"] == "" assert parsed["attestor_fingerprint"] == "" assert parsed["content_type"] == "image" # ═══════════════════════════════════════════════════════════════════════════════ # Section 2: cert.py # ═══════════════════════════════════════════════════════════════════════════════ def _load_cert(pem: bytes | str) -> x509.Certificate: if isinstance(pem, str): pem = pem.encode() return x509.load_pem_x509_certificate(pem) class TestGenerateCert: def test_org_privacy_level_cn(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="Acme Newsroom") pem = _generate_cert(key, config, "org") cert = _load_cert(pem) cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value assert "Acme Newsroom" in cn def test_org_privacy_level_cn_fallback(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="") pem = _generate_cert(key, config, "org") cert = _load_cert(pem) cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value assert "FieldWitness" in cn def test_pseudonym_privacy_level_cn(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="NightOwl") pem = _generate_cert(key, config, "pseudonym") cert = _load_cert(pem) cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value assert cn == "NightOwl" def test_pseudonym_privacy_level_cn_fallback(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="") pem = _generate_cert(key, config, "pseudonym") cert = _load_cert(pem) cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value assert cn == "FieldWitness User" def test_anonymous_privacy_level_cn(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="ShouldNotAppear") pem = _generate_cert(key, config, "anonymous") cert = _load_cert(pem) cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value # Anonymous certs must not reveal org/cover name assert cn == "FieldWitness" assert "ShouldNotAppear" not in cn def test_unrecognised_privacy_level_falls_back_to_anonymous(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="ShouldNotAppear") pem = _generate_cert(key, config, "unknown_level") cert = _load_cert(pem) cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value assert cn == "FieldWitness" def test_uses_ed25519_algorithm(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() pem = _generate_cert(key, config, "org") cert = _load_cert(pem) # Ed25519 is identified by OID 1.3.101.112 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey assert isinstance(cert.public_key(), Ed25519PublicKey) def test_basic_constraints_ca_false(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() pem = _generate_cert(key, config, "org") cert = _load_cert(pem) bc = cert.extensions.get_extension_for_class(x509.BasicConstraints) assert bc.value.ca is False def test_key_usage_digital_signature_only(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() pem = _generate_cert(key, config, "org") cert = _load_cert(pem) ku = cert.extensions.get_extension_for_class(x509.KeyUsage).value assert ku.digital_signature is True assert ku.content_commitment is False assert ku.key_encipherment is False assert ku.data_encipherment is False assert ku.key_agreement is False assert ku.key_cert_sign is False assert ku.crl_sign is False def test_validity_approximately_ten_years(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() pem = _generate_cert(key, config, "org") cert = _load_cert(pem) not_before = cert.not_valid_before_utc not_after = cert.not_valid_after_utc delta = not_after - not_before # 10 years = 3650 days; allow ±2 days for leap-year variation assert abs(delta.days - _CERT_VALIDITY_YEARS * 365) <= 2 def test_public_key_matches_private_key(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() pem = _generate_cert(key, config, "org") cert = _load_cert(pem) expected_raw = key.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) actual_raw = cert.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) assert expected_raw == actual_raw def test_self_signed_issuer_equals_subject(self) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() pem = _generate_cert(key, config, "org") cert = _load_cert(pem) assert cert.issuer == cert.subject class TestGetOrCreateC2paCert: def test_creates_cert_on_first_call(self, tmp_path: Path) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig(cover_name="TestOrg") identity_dir = tmp_path / "identity" identity_dir.mkdir() cert_pem, returned_key = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) assert (identity_dir / "c2pa_cert.pem").exists() assert "BEGIN CERTIFICATE" in cert_pem # Returned key is the same object passed in assert returned_key is key def test_returns_existing_cert_on_second_call(self, tmp_path: Path) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() identity_dir = tmp_path / "identity" identity_dir.mkdir() cert_pem_1, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) cert_pem_2, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) # Both calls return identical PEM content assert cert_pem_1 == cert_pem_2 def test_returns_existing_cert_without_regenerating(self, tmp_path: Path) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() identity_dir = tmp_path / "identity" identity_dir.mkdir() get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) cert_path = identity_dir / "c2pa_cert.pem" mtime_after_first = cert_path.stat().st_mtime get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) mtime_after_second = cert_path.stat().st_mtime # File was not rewritten on second call assert mtime_after_first == mtime_after_second def test_detects_key_mismatch_and_regenerates(self, tmp_path: Path) -> None: key_a = Ed25519PrivateKey.generate() key_b = Ed25519PrivateKey.generate() config = FieldWitnessConfig() identity_dir = tmp_path / "identity" identity_dir.mkdir() # Write cert for key_a cert_pem_a, _ = get_or_create_c2pa_cert(config, key_a, identity_dir=identity_dir) # Supply key_b — should detect mismatch and regenerate cert_pem_b, _ = get_or_create_c2pa_cert(config, key_b, identity_dir=identity_dir) # The two PEMs must differ (different key embedded in cert) assert cert_pem_a != cert_pem_b # Verify the new cert actually encodes key_b cert = _load_cert(cert_pem_b) expected_raw = key_b.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) actual_raw = cert.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) assert expected_raw == actual_raw def test_force_regenerates_even_with_matching_key(self, tmp_path: Path) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() identity_dir = tmp_path / "identity" identity_dir.mkdir() get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) cert_path = identity_dir / "c2pa_cert.pem" mtime_after_first = cert_path.stat().st_mtime # Small sleep to ensure mtime can differ import time time.sleep(0.01) get_or_create_c2pa_cert(config, key, force=True, identity_dir=identity_dir) mtime_after_force = cert_path.stat().st_mtime assert mtime_after_force >= mtime_after_first def test_cert_public_key_matches_private_key(self, tmp_path: Path) -> None: key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() identity_dir = tmp_path / "identity" identity_dir.mkdir() cert_pem, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) cert = _load_cert(cert_pem) expected_raw = key.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) actual_raw = cert.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) assert expected_raw == actual_raw # ═══════════════════════════════════════════════════════════════════════════════ # Section 3: export.py -- GPS downsampling and assertion building # ═══════════════════════════════════════════════════════════════════════════════ class TestDownsampleGps: """Verify _downsample_gps uses unbiased rounding (round(), not math.floor()).""" def test_positive_coordinates_round_to_nearest(self) -> None: # 40.06 should round to 40.1 (nearest), not 40.0 (floor) lat, lon = _downsample_gps(40.06, 74.06) assert lat == pytest.approx(40.1, abs=1e-9) assert lon == pytest.approx(74.1, abs=1e-9) def test_positive_coordinates_round_down_when_below_half(self) -> None: # 40.04 should round to 40.0 (nearest) lat, lon = _downsample_gps(40.04, 74.04) assert lat == pytest.approx(40.0, abs=1e-9) assert lon == pytest.approx(74.0, abs=1e-9) def test_negative_coordinates_round_to_nearest(self) -> None: # -40.05 should round to -40.1 (nearest), not -40.0 (floor would give -41.0) lat, lon = _downsample_gps(-40.05, -74.05) # Python's built-in round() uses banker's rounding for exact .5 — tolerate # either -40.0 or -40.1 at the boundary; check directional correctness. assert abs(lat) == pytest.approx(40.0, abs=0.2) assert abs(lon) == pytest.approx(74.0, abs=0.2) def test_negative_lat_not_systematically_biased_south(self) -> None: # With math.floor: floor(-40.04 * 10) / 10 = floor(-400.4) / 10 = -401/10 = -40.1 # With round(): round(-40.04, 1) = -40.0 # We verify the result is -40.0 (nearest), not -40.1 (biased south). lat, _ = _downsample_gps(-40.04, 0.0) assert lat == pytest.approx(-40.0, abs=1e-9) def test_negative_lon_not_systematically_biased_west(self) -> None: # With math.floor: floor(-74.04 * 10) / 10 = -74.1 (biased west) # With round(): round(-74.04, 1) = -74.0 (correct) _, lon = _downsample_gps(0.0, -74.04) assert lon == pytest.approx(-74.0, abs=1e-9) def test_equator_and_prime_meridian(self) -> None: lat, lon = _downsample_gps(0.0, 0.0) assert lat == pytest.approx(0.0, abs=1e-9) assert lon == pytest.approx(0.0, abs=1e-9) def test_exact_grid_point_unchanged(self) -> None: lat, lon = _downsample_gps(51.5, -0.1) assert lat == pytest.approx(51.5, abs=1e-9) assert lon == pytest.approx(-0.1, abs=1e-9) def test_city_boundary_positive_side(self) -> None: # 50.96 rounds to 51.0, not 50.0 (floor would give 50.0) lat, lon = _downsample_gps(50.96, 10.96) assert lat == pytest.approx(51.0, abs=1e-9) assert lon == pytest.approx(11.0, abs=1e-9) def test_city_boundary_negative_side(self) -> None: # -50.94 rounds to -50.9, not -51.0 (floor bias) lat, _ = _downsample_gps(-50.94, 0.0) assert lat == pytest.approx(-50.9, abs=1e-9) def test_output_precision_is_one_decimal(self) -> None: lat, lon = _downsample_gps(48.8566, 2.3522) # Paris # Both values should be expressible as X.X assert round(lat, 1) == lat assert round(lon, 1) == lon class TestBuildExifAssertion: def _record_with_location( self, lat: float, lon: float, altitude: float | None = None, ) -> AttestationRecord: loc = GeoLocation( latitude=lat, longitude=lon, altitude_meters=altitude, ) cm = CaptureMetadata(location=loc) return _make_record(metadata=cm.to_dict()) def test_gps_omitted_when_no_location(self) -> None: record = _make_record(metadata={}) result = _build_exif_assertion(record, include_precise_gps=False) assert result is None def test_gps_downsampled_when_include_precise_false(self) -> None: record = self._record_with_location(40.04, -74.04) exif = _build_exif_assertion(record, include_precise_gps=False) assert exif is not None # 40.04 rounds to 40.0, -74.04 rounds to -74.0 assert exif["GPSLatitude"] == pytest.approx(40.0, abs=1e-9) assert exif["GPSLongitude"] == pytest.approx(74.0, abs=1e-9) def test_gps_precise_when_include_precise_true(self) -> None: record = self._record_with_location(40.7128, -74.0060) exif = _build_exif_assertion(record, include_precise_gps=True) assert exif is not None assert exif["GPSLatitude"] == pytest.approx(40.7128, abs=1e-9) assert exif["GPSLongitude"] == pytest.approx(74.0060, abs=1e-9) def test_gps_latitude_ref_north_positive(self) -> None: record = self._record_with_location(48.8, 2.3) exif = _build_exif_assertion(record, include_precise_gps=True) assert exif["GPSLatitudeRef"] == "N" assert exif["GPSLongitudeRef"] == "E" def test_gps_latitude_ref_south_negative(self) -> None: record = self._record_with_location(-33.9, -70.7) exif = _build_exif_assertion(record, include_precise_gps=True) assert exif["GPSLatitudeRef"] == "S" assert exif["GPSLongitudeRef"] == "W" def test_altitude_included_when_precise_and_present(self) -> None: record = self._record_with_location(48.8, 2.3, altitude=250.0) exif = _build_exif_assertion(record, include_precise_gps=True) assert exif["GPSAltitude"] == pytest.approx(250.0, abs=1e-9) assert exif["GPSAltitudeRef"] == 0 # above sea level def test_altitude_omitted_when_not_precise(self) -> None: record = self._record_with_location(48.8, 2.3, altitude=250.0) exif = _build_exif_assertion(record, include_precise_gps=False) assert "GPSAltitude" not in exif # type: ignore[operator] def test_negative_altitude_ref_is_one(self) -> None: record = self._record_with_location(0.0, 0.0, altitude=-50.0) exif = _build_exif_assertion(record, include_precise_gps=True) assert exif["GPSAltitudeRef"] == 1 # below sea level def test_returns_none_when_no_capture_metadata(self) -> None: record = _make_record(metadata={}) assert _build_exif_assertion(record, include_precise_gps=False) is None def test_device_fields_included(self) -> None: dev = CaptureDevice(make="Apple", model="iPhone 15 Pro", software="iOS 17") cm = CaptureMetadata(device=dev) record = _make_record(metadata=cm.to_dict()) exif = _build_exif_assertion(record, include_precise_gps=False) assert exif is not None assert exif["Make"] == "Apple" assert exif["Model"] == "iPhone 15 Pro" assert exif["Software"] == "iOS 17" # serial_hash must never be in the C2PA exif (privacy by design) assert "serial_hash" not in exif assert "SerialNumber" not in exif def test_timestamp_format(self) -> None: ts = datetime.datetime(2024, 3, 15, 12, 30, 45, tzinfo=datetime.UTC) cm = CaptureMetadata(captured_at=ts) record = _make_record(metadata=cm.to_dict()) exif = _build_exif_assertion(record, include_precise_gps=False) assert exif is not None assert exif["DateTimeOriginal"] == "2024:03:15 12:30:45" # ═══════════════════════════════════════════════════════════════════════════════ # Section 4: export.py -- full export_c2pa path (requires c2pa-python) # ═══════════════════════════════════════════════════════════════════════════════ @pytest.mark.skipif( True, # Always skip — c2pa-python is not in the CI environment reason="c2pa-python not installed; full Builder.sign() path skipped", ) class TestExportC2paIntegration: """Full round-trip tests that require c2pa-python. These are kept here as documentation and can be enabled locally by removing the skipif mark once the [c2pa] extra is installed. """ def test_export_returns_bytes(self, tmp_path: Path) -> None: c2pa = pytest.importorskip("c2pa") # noqa: F841 key = Ed25519PrivateKey.generate() config = FieldWitnessConfig() identity_dir = tmp_path / "identity" identity_dir.mkdir() cert_pem, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir) # Minimal 1x1 white JPEG from fieldwitness.c2pa_bridge.export import export_c2pa minimal_jpeg = bytes( [ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9, ] ) record = _make_record() result = export_c2pa(minimal_jpeg, "jpeg", record, key, cert_pem) assert isinstance(result, bytes) assert len(result) > len(minimal_jpeg)