//! TRUSTY_PG_URL-gated integration tests for the higher-level //! `Trusty` surface on Postgres. Pays down the test debt accumulated //! across S16 cycles 4-7 (append_signed, append_batch + advisory //! lock, anchor, anchors_since, export_bundle, checkpoint, //! latest_checkpoint, verify_since_checkpoint). //! //! Setup mirrors `tests/conformance.rs`: when TRUSTY_PG_URL is unset //! the tests print SKIP and exit clean so `cargo test --workspace` //! stays hermetic. Set it to a libpq connection string pointing at a //! disposable test DB to actually run them. use serde_json::json; use trusty_core::{Ed25519Signer, ProofBundle, SecretKey, Signer}; use trusty_postgres::{RowSpec, Trusty}; const ENV_URL: &str = "TRUSTY_PG_URL"; fn maybe_url() -> Option { std::env::var(ENV_URL).ok() } fn drop_tables(url: &str) { let mut client = postgres::Client::connect(url, postgres::NoTls).expect("connect"); client .batch_execute( "DROP TABLE IF EXISTS chain_checkpoint CASCADE; DROP TABLE IF EXISTS producer_revocation CASCADE; DROP TABLE IF EXISTS producer_identity CASCADE; DROP TABLE IF EXISTS chain_tips CASCADE; DROP TABLE IF EXISTS hash_index CASCADE; DROP TABLE IF EXISTS provenance_envelope CASCADE; DROP TABLE IF EXISTS schema_meta CASCADE;", ) .expect("drop tables"); } fn open_clean(url: &str) -> Trusty { drop_tables(url); let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key"); Trusty::open(url, key).expect("open Trusty") } fn signer() -> Ed25519Signer { let seed = [9u8; 32]; Ed25519Signer::from_secret_bytes(&seed).expect("signer") } fn skip_if_no_url() -> Option { let url = maybe_url(); if url.is_none() { eprintln!( "SKIP: set {} to a Postgres connection string to run integration tests", ENV_URL ); } url } #[test] fn append_signed_writes_v2_payload_and_verifies() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); let r = t .append_signed("dev1", &json!({"v": 1}), "src", &s) .expect("append_signed"); assert!(!r.chain_break); assert_eq!(r.sequence, 0); assert!(r.is_genesis); // Payload should be v2 with a populated signature object. Read // raw_payload back from the row directly to confirm. let mut client = postgres::Client::connect(&url, postgres::NoTls).expect("connect"); let row = client .query_one( "SELECT raw_payload FROM provenance_envelope WHERE entity_id = $1 ORDER BY id ASC LIMIT 1", &[&"dev1"], ) .expect("read row"); let raw: String = row.get(0); assert!(raw.contains("\"payload_version\":2"), "raw = {}", raw); assert!(raw.contains("\"alg\":\"ed25519\""), "raw = {}", raw); let report = t.verify("dev1").expect("verify"); assert!(report.rows_valid, "{:?}", report.errors); assert_eq!(report.row_count, 1); } #[test] fn append_batch_is_all_or_nothing_and_advisory_lock_present() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let d0 = json!({"v": 0}); let d1 = json!({"v": 1}); let d2 = json!({"v": 2}); let specs = vec![ RowSpec { entity_id: "fleet-a", data: &d0, source_url: "src", }, RowSpec { entity_id: "fleet-a", data: &d1, source_url: "src", }, RowSpec { entity_id: "fleet-b", data: &d2, source_url: "src", }, ]; let results = t.append_batch(&specs).expect("append_batch"); assert_eq!(results.len(), 3); assert!(t.verify("fleet-a").expect("verify a").rows_valid); assert!(t.verify("fleet-b").expect("verify b").rows_valid); // Empty batch is a no-op. let empty: Vec = Vec::new(); let none = t.append_batch(&empty).expect("empty batch"); assert!(none.is_empty()); } #[test] fn anchor_and_anchors_since_round_trip() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); for i in 0..3 { t.append("dev2", &json!({"i": i}), "src").expect("append"); } let anchor = t.anchor("dev2").expect("anchor").expect("some"); assert_eq!(anchor.entity_id, "dev2"); assert!(anchor.chain_id.starts_with("chain_dev2_")); assert!(anchor.tip_hash.is_some()); let entries = t.anchors_since("dev2", None).expect("anchors_since"); // Three appends -> three rows -> three anchor commitments. assert_eq!(entries.len(), 3); // Each entry has a distinct sequence and non-empty hash. for (i, e) in entries.iter().enumerate() { assert_eq!(e.sequence, i as i64); assert!(!e.anchor_hash.is_empty()); } // since-filter uses strict-greater-than on detected_at; a value // beyond all rows yields the empty list. let none = t .anchors_since("dev2", Some("9999-01-01T00:00:00Z")) .expect("anchors_since since=future"); assert!(none.is_empty()); } #[test] fn export_bundle_round_trips_through_proof_bundle_verify() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key"); for i in 0..4 { t.append("dev3", &json!({"i": i}), "src").expect("append"); } let bundle: ProofBundle = t.export_bundle("dev3", None, None).expect("export_bundle"); let report = bundle.verify(&key); assert!( report.rows_valid, "bundle verify failed: {:?}", report.errors ); assert_eq!(report.row_count, 4); // Sequence-bounded slice still verifies. let slice = t .export_bundle("dev3", Some(1), Some(2)) .expect("export_bundle slice"); let slice_report = slice.verify(&key); assert!(slice_report.rows_valid, "{:?}", slice_report.errors); assert_eq!(slice_report.row_count, 2); } #[test] fn checkpoint_writes_then_latest_returns_it() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); for i in 0..3 { t.append("dev4", &json!({"i": i}), "src").expect("append"); } let ckpt = t.checkpoint("dev4", "oid:1.2.3", &s).expect("checkpoint"); assert_eq!(ckpt.attested_by, "oid:1.2.3"); assert_eq!(ckpt.sequence_int, 2); assert!(!ckpt.row_hash.is_empty()); assert!(!ckpt.attestation_sig.is_empty()); let latest = t .latest_checkpoint(&ckpt.chain_id) .expect("latest_checkpoint") .expect("some"); assert_eq!(latest.id, ckpt.id); assert_eq!(latest.sequence_int, ckpt.sequence_int); assert_eq!(latest.row_hash, ckpt.row_hash); assert_eq!(latest.attestation_sig, ckpt.attestation_sig); } #[test] fn verify_since_checkpoint_falls_back_when_no_checkpoint() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); for i in 0..2 { t.append("dev5", &json!({"i": i}), "src").expect("append"); } // No checkpoint written -> verify_since_checkpoint must return // the same shape as a full verify. let full = t.verify("dev5").expect("verify"); let pruned = t.verify_since_checkpoint("dev5").expect("vsc"); assert_eq!(full.rows_valid, pruned.rows_valid); assert_eq!(full.row_count, pruned.row_count); } #[test] fn verify_since_checkpoint_after_checkpoint_still_passes() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); // S16 cycle 9: verify_since_checkpoint authenticates the // checkpoint against producer_identity before trusting the // window-prune. Register the attestor first. t.register_producer( "oid:test", &s.public_key_bytes(), b"siphash_key_bytes_16", Some("test attestor"), ) .expect("register producer"); for i in 0..3 { t.append("dev6", &json!({"i": i}), "src").expect("append"); } t.checkpoint("dev6", "oid:test", &s).expect("checkpoint"); // Append more rows after the checkpoint. for i in 3..5 { t.append("dev6", &json!({"i": i}), "src").expect("append"); } let report = t.verify_since_checkpoint("dev6").expect("vsc"); assert!(report.rows_valid, "{:?}", report.errors); // Verify covers checkpoint.sequence (=2) forward -> rows 2,3,4 // -> 3 rows. assert_eq!(report.row_count, 3); } #[test] fn producer_identity_register_and_lookup_round_trip() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); let pk = s.public_key_bytes(); t.register_producer("oid:1.2.3", &pk, b"sip-key-1", Some("agent-a")) .expect("register"); let got = t .lookup_producer("oid:1.2.3") .expect("lookup") .expect("some"); assert_eq!(got.producer_oid, "oid:1.2.3"); assert_eq!(got.ed25519_pubkey, pk); assert_eq!(got.siphash_key, b"sip-key-1"); assert_eq!(got.display_name.as_deref(), Some("agent-a")); // Re-register same OID is an UPSERT. let s2 = trusty_core::Ed25519Signer::from_secret_bytes(&[3u8; 32]).expect("s2"); let pk2 = s2.public_key_bytes(); t.register_producer("oid:1.2.3", &pk2, b"sip-key-2", None) .expect("re-register"); let updated = t .lookup_producer("oid:1.2.3") .expect("lookup2") .expect("some"); assert_eq!(updated.ed25519_pubkey, pk2); assert_eq!(updated.siphash_key, b"sip-key-2"); assert!(updated.display_name.is_none()); // Unknown OID yields None. assert!(t.lookup_producer("oid:nope").expect("lookup3").is_none()); } #[test] fn verify_since_checkpoint_rejects_unknown_attestor() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); // Append rows + write checkpoint WITHOUT registering the // attestor first. The checkpoint row is in the table but // attested_by has no producer_identity row. for i in 0..2 { t.append("dev7", &json!({"i": i}), "src").expect("append"); } t.checkpoint("dev7", "oid:ghost", &s) .expect("checkpoint write succeeds (lookup happens at verify)"); let report = t.verify_since_checkpoint("dev7").expect("vsc"); assert!( !report.rows_valid, "expected failure for unregistered attestor" ); assert_eq!(report.errors.len(), 1); let detail = match &report.errors[0].kind { trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(), other => panic!("expected SignatureInvalid, got {:?}", other), }; assert!( detail.contains("not in producer_identity"), "detail = {}", detail ); } #[test] fn verify_with_producer_accepts_signed_rows_by_registered_producer() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None) .expect("register"); // Three signed rows by the registered producer. for i in 0..3 { t.append_signed("dev-rsa1", &json!({"i": i}), "src", &s) .expect("append_signed"); } let report = t .verify_with_producer("dev-rsa1", "oid:test") .expect("verify_with_producer"); assert!(report.rows_valid, "{:?}", report.errors); assert_eq!(report.row_count, 3); } #[test] fn verify_with_producer_rejects_row_signed_by_unauthorized_pubkey() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let registered = signer(); let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[77u8; 32]).expect("imp"); t.register_producer("oid:test", ®istered.public_key_bytes(), b"sip-key", None) .expect("register"); // Row 0 signed by the registered producer (legitimate). t.append_signed("dev-rsa2", &json!({"i": 0}), "src", ®istered) .expect("append_signed legit"); // Row 1 signed by the imposter -- chain math still verifies, but // verify_with_producer must flag it because the pk doesn't match // producer_identity for oid:test. t.append_signed("dev-rsa2", &json!({"i": 1}), "src", &imposter) .expect("append_signed imposter"); let report = t .verify_with_producer("dev-rsa2", "oid:test") .expect("verify_with_producer"); assert!(!report.rows_valid, "expected rejection of unauthorized pk"); let unauthorized = report .errors .iter() .find(|e| match &e.kind { trusty_core::VerifyErrorType::SignatureInvalid { detail } => { detail.contains("unauthorized pubkey") } _ => false, }) .expect("expected an 'unauthorized pubkey' SignatureInvalid"); assert_eq!(unauthorized.sequence, 1); } #[test] fn verify_with_producer_rejects_row_after_revocation() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None) .expect("register"); // Revoke FIRST, then append. The row's detected_at will be // strictly greater than revoked_at, so it must reject. t.revoke_producer("oid:test", "compromise", &s) .expect("revoke"); std::thread::sleep(std::time::Duration::from_millis(50)); t.append_signed("dev-rsa3", &json!({"i": 0}), "src", &s) .expect("append_signed after revoke"); let report = t .verify_with_producer("dev-rsa3", "oid:test") .expect("verify_with_producer"); assert!(!report.rows_valid); let after_rev = report.errors.iter().find(|e| match &e.kind { trusty_core::VerifyErrorType::SignatureInvalid { detail } => { detail.contains("AT or AFTER revocation") } _ => false, }); assert!( after_rev.is_some(), "expected post-revocation rejection; errors: {:?}", report.errors ); } #[test] fn verify_with_producer_accepts_rows_signed_before_revocation() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None) .expect("register"); // Append FIRST, then revoke. CT stance: past rows stay valid. for i in 0..2 { t.append_signed("dev-rsa4", &json!({"i": i}), "src", &s) .expect("append_signed"); } std::thread::sleep(std::time::Duration::from_millis(50)); t.revoke_producer("oid:test", "later compromise", &s) .expect("revoke"); let report = t .verify_with_producer("dev-rsa4", "oid:test") .expect("verify_with_producer"); assert!( report.rows_valid, "expected pre-revocation rows to remain valid; errors: {:?}", report.errors ); assert_eq!(report.row_count, 2); } #[test] fn revoke_producer_round_trip_and_lookup() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None) .expect("register"); // Not revoked yet. assert!(t.lookup_revocation("oid:test").expect("lookup").is_none()); // Revoke. Returns RevocationRecord; producer_revocation row exists. let rec = t .revoke_producer("oid:test", "key compromise", &s) .expect("revoke"); assert_eq!(rec.producer_oid, "oid:test"); assert_eq!(rec.reason, "key compromise"); assert_eq!(rec.revoked_pubkey, s.public_key_bytes()); assert_eq!(rec.attested_by_pubkey, s.public_key_bytes()); assert!(!rec.revocation_row_hash.is_empty()); // Lookup returns the same record. let got = t .lookup_revocation("oid:test") .expect("lookup") .expect("some"); assert_eq!(got.producer_oid, rec.producer_oid); assert_eq!(got.revocation_row_hash, rec.revocation_row_hash); // The REVOCATION envelope is on the per-user 'revocations' chain // and verifies via the standard verify path. let report = t.verify("revocations").expect("verify revocations chain"); assert!(report.rows_valid, "{:?}", report.errors); assert_eq!(report.row_count, 1); // Re-revoking the same OID UPSERTs (single row per OID). let rec2 = t .revoke_producer("oid:test", "second reason", &s) .expect("re-revoke"); assert_eq!(rec2.reason, "second reason"); // Two REVOCATION envelopes on chain (append-only); one // producer_revocation index row (UPSERT). let report2 = t .verify("revocations") .expect("verify revocations chain v2"); assert!(report2.rows_valid); assert_eq!(report2.row_count, 2); } #[test] fn revoke_producer_unknown_oid_errors() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); let err = t.revoke_producer("oid:ghost", "no such producer", &s); assert!(err.is_err(), "expected NotFound for unregistered OID"); } #[test] fn verify_since_checkpoint_rejects_revoked_attestor_after_revocation() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None) .expect("register"); for i in 0..3 { t.append("dev-rev1", &json!({"i": i}), "src") .expect("append"); } // Revoke FIRST, then write checkpoint. Per CT stance, this // checkpoint's checkpoint_at >= revoked_at, so it must reject. t.revoke_producer("oid:test", "compromise", &s) .expect("revoke"); t.checkpoint("dev-rev1", "oid:test", &s) .expect("checkpoint write succeeds (verify happens later)"); let report = t.verify_since_checkpoint("dev-rev1").expect("vsc"); assert!(!report.rows_valid, "expected rejection of revoked attestor"); let detail = match &report.errors[0].kind { trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(), other => panic!("expected SignatureInvalid, got {:?}", other), }; assert!(detail.contains("revoked at"), "detail = {}", detail); } #[test] fn verify_since_checkpoint_accepts_attestor_revoked_after_checkpoint() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let s = signer(); t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None) .expect("register"); for i in 0..3 { t.append("dev-rev2", &json!({"i": i}), "src") .expect("append"); } // Checkpoint FIRST, then revoke. Per CT stance, this checkpoint's // checkpoint_at < revoked_at, so it must still accept. t.checkpoint("dev-rev2", "oid:test", &s) .expect("checkpoint"); // Sleep briefly to ensure a strictly-greater revoked_at. std::thread::sleep(std::time::Duration::from_millis(50)); t.revoke_producer("oid:test", "later compromise", &s) .expect("revoke"); let report = t.verify_since_checkpoint("dev-rev2").expect("vsc"); assert!( report.rows_valid, "expected past checkpoint to remain valid; errors: {:?}", report.errors ); } #[test] fn verify_since_checkpoint_rejects_tampered_attestation_sig() { let Some(url) = skip_if_no_url() else { return }; let mut t = open_clean(&url); let attestor = signer(); let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[42u8; 32]).expect("imp"); // Register the *attestor*'s pubkey under the OID, but write the // checkpoint signed by the *imposter*. Sig verification must // fail. t.register_producer( "oid:test", &attestor.public_key_bytes(), b"siphash-key-XYZ", None, ) .expect("register"); for i in 0..2 { t.append("dev8", &json!({"i": i}), "src").expect("append"); } t.checkpoint("dev8", "oid:test", &imposter) .expect("checkpoint with imposter signer"); let report = t.verify_since_checkpoint("dev8").expect("vsc"); assert!(!report.rows_valid, "expected failure for tampered sig"); let detail = match &report.errors[0].kind { trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(), other => panic!("expected SignatureInvalid, got {:?}", other), }; assert!( detail.contains("attestation_sig invalid"), "detail = {}", detail ); } // --------------------------------------------------------------------- // Per-entity lock on the single-append path. // --------------------------------------------------------------------- /// Two writers, two connections, one entity, at the same time. /// /// Appending is read-tip, compute, insert, and those three steps are not /// atomic. Without a per-entity lock both writers read the same tip and /// both build on it, producing two rows that claim the same predecessor /// -- a fork in a structure whose whole promise is that there is one /// line. /// /// The batch path has held a lock since it was written. The single /// path never did; the module header said "add before any multi-writer /// deployment", and nothing was enforcing it. #[test] fn concurrent_appends_to_one_entity_do_not_fork_the_chain() { let Some(url) = maybe_url() else { eprintln!("SKIP: {ENV_URL} unset"); return; }; let entity = "concurrency-probe"; // A unique entity per run, so a previous run cannot make this pass // and no cleanup accessor is needed on the public surface. let entity = &format!( "{entity}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() ); // Apply the schema ONCE. Four threads each running // CREATE TABLE IF NOT EXISTS collide in the catalog with "tuple // concurrently updated" -- a real property of concurrent DDL, and // not what this test is about. { let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key"); Trusty::open(&url, key).expect("apply schema"); } const WRITERS: usize = 4; const EACH: usize = 6; let handles: Vec<_> = (0..WRITERS) .map(|w| { let url = url.clone(); let entity = entity.clone(); std::thread::spawn(move || { let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key"); let mut t = Trusty::open(&url, key).expect("open"); for i in 0..EACH { t.append(&entity, &json!({ "writer": w, "i": i }), "test://") .expect("append"); } }) }) .collect(); for h in handles { h.join().expect("writer panicked"); } let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key"); let mut t = Trusty::open(&url, key).expect("open"); let bundle = t.export_bundle(entity, None, None).expect("export"); let rows: Vec<_> = bundle .rows .iter() .filter(|r| r.event_type != "BREAK") .collect(); assert_eq!( rows.len(), WRITERS * EACH, "every append must land exactly once" ); // The real assertion: no two rows may claim the same predecessor. // That is what a lost update looks like from the outside. let mut prevs: Vec<&str> = rows.iter().map(|r| r.prev_hash.as_str()).collect(); prevs.sort_unstable(); let before = prevs.len(); prevs.dedup(); assert_eq!( prevs.len(), before, "two rows claim the same prev_hash, so two writers read the same tip \ and both built on it -- the chain forked, which is exactly what the \ per-entity lock exists to prevent" ); let report = t.verify(entity).expect("verify"); assert!( report.rows_valid, "and the chain must still verify end to end: {report:?}" ); }