fieldwitness/tests/test_c2pa_bridge.py
Aaron D. Lee 5b0d90eeaf
Some checks failed
CI / lint (push) Failing after 12s
CI / typecheck (push) Failing after 12s
Fix all power-user review issues (FR-01 through FR-12)
FR-01: Fix data directory default from ~/.fieldwitness to ~/.fwmetadata
FR-02/05/07: Accept all file types for attestation (not just images)
  - Web UI, CLI, and batch now accept PDFs, CSVs, audio, video, etc.
  - Perceptual hashing for images, SHA-256-only for everything else
FR-03: Implement C2PA import path + CLI commands (export/verify/import/show)
FR-04: Fix GPS downsampling bias (math.floor → round)
FR-06: Add HTML/PDF evidence summaries for lawyers
  - Always generates summary.html, optional summary.pdf via xhtml2pdf
FR-08: Fix CLI help text ("FieldWitness -- FieldWitness" artifact)
FR-09: Centralize stray paths (trusted_keys, carrier_history, last_backup)
FR-10: Add 67 C2PA bridge tests (vendor assertions, cert, GPS, export)
FR-12: Add Tor onion service support for source drop box
  - fieldwitness serve --tor flag, persistent/transient modes
  - Killswitch covers hidden service keys

Also: bonus fix for attest/api.py hardcoded path bypassing paths.py

224 tests passing (67 new).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:10:37 -04:00

822 lines
32 KiB
Python

"""
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)