diff --git a/tests/test_catalog_fingerprint.py b/tests/test_catalog_fingerprint.py new file mode 100644 index 0000000..9aa49ae --- /dev/null +++ b/tests/test_catalog_fingerprint.py @@ -0,0 +1,17 @@ +import hashlib +import json + +from townlight_core.catalog import fingerprint + + +def test_catalog_fingerprint_preserves_digest_across_database_collations(): + canonical_rows = [ + {"domain": "Utilities", "vendor_name": "CIS Infinity", "access_protocol": "odbc"}, + {"domain": "Utilities", "vendor_name": "Cartegraph", "access_protocol": "rest_api"}, + ] + previous_digest = hashlib.sha256( + json.dumps(canonical_rows, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + assert fingerprint(reversed(canonical_rows)) == previous_digest + assert fingerprint(canonical_rows[:1]) != previous_digest diff --git a/townlight_core/catalog/__init__.py b/townlight_core/catalog/__init__.py index f181179..85eb53d 100644 --- a/townlight_core/catalog/__init__.py +++ b/townlight_core/catalog/__init__.py @@ -66,6 +66,10 @@ def unique_entries(self): return self +def catalog_row_key(row): + return row["domain"], row["vendor_name"], row["access_protocol"] + + def normalized_rows(bundle: CatalogInput): return sorted( [ @@ -73,13 +77,15 @@ def normalized_rows(bundle: CatalogInput): for domain in bundle.domains for system in domain.systems ], - key=lambda r: (r["domain"], r["vendor_name"], r["access_protocol"]), + key=catalog_row_key, ) def fingerprint(rows): + # Database collation can order the same keys differently from Python. + canonical_rows = sorted(rows, key=catalog_row_key) return hashlib.sha256( - json.dumps(rows, sort_keys=True, separators=(",", ":")).encode() + json.dumps(canonical_rows, sort_keys=True, separators=(",", ":")).encode() ).hexdigest()