commit c8e0a9b614a8bc6a725028ed8df5a0c5c0477fb9
parent ccee5a9ecd935fe4bd40a30e3d5eb584ef55b715
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 23:43:01 +0000
test(rhi): close foundation integration wave
Diffstat:
5 files changed, 260 insertions(+), 3 deletions(-)
diff --git a/src/identity_envelope.rs b/src/identity_envelope.rs
@@ -1242,6 +1242,7 @@ mod tests {
candidate
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
fn different_identity_secret() -> [u8; 32] {
let mut candidate = bytes("radroots.rhi.test-only.different-identity-secret.v1");
while SecretKey::from_slice(&candidate).is_err() {
@@ -1295,6 +1296,7 @@ mod tests {
(configuration, metadata)
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
fn binding(root: &Path, path: &Path) -> RhiIdentityEnvelopeBinding {
let (configuration, metadata) = binding_authority(root, path);
RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata)
diff --git a/src/runtime_adapters.rs b/src/runtime_adapters.rs
@@ -447,8 +447,8 @@ mod tests {
};
use radroots_service_host::{
- EntropyError, HostError, MonotonicClockError, TaskClassification, TaskMetadata, TaskName,
- WallClockError,
+ EntropyError, HostError, MonotonicClockError, ShutdownPhase, TaskClassification,
+ TaskMetadata, TaskName, WallClockError,
};
use radroots_transport::{
BoxFuture, DeliveryReceipt, DeliveryRequest, EventSubscription, FetchPage, FetchRequest,
@@ -729,6 +729,42 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn adapter_shutdown_cancels_and_joins_long_lived_tasks() {
+ let transport = Arc::new(NoIoTransport);
+ let transports = RhiTransportAdapters::new(transport.clone(), transport.clone(), transport);
+ let mut adapters = RhiRuntimeAdapters::new(
+ RhiTimeEntropyAdapters::new(
+ FixedWall(Ok(UnixTimeSeconds::new(1))),
+ FixedMonotonic(MonotonicTime::from_duration_since_origin(Duration::ZERO)),
+ FixedEntropy(Ok(1)),
+ ),
+ transports,
+ RhiIdentityCredentialAdapters::canonical(),
+ );
+ let cancellations = Arc::new(AtomicUsize::new(0));
+ let task_cancellations = Arc::clone(&cancellations);
+ adapters
+ .supervisor_mut()
+ .spawn(
+ TaskMetadata::new(
+ TaskName::new("adapter_cancellation_test").expect("task name"),
+ TaskClassification::Critical,
+ Some(ShutdownPhase::CloseNetwork),
+ )
+ .expect("metadata"),
+ move |cancel| async move {
+ cancel.cancelled().await;
+ task_cancellations.fetch_add(1, Ordering::Relaxed);
+ Ok::<(), HostError>(())
+ },
+ )
+ .expect("register");
+ adapters.shutdown().await.expect("joined shutdown");
+ assert_eq!(cancellations.load(Ordering::Relaxed), 1);
+ assert_eq!(adapters.supervised_task_count(), 0);
+ }
+
#[test]
fn all_error_codes_messages_and_debug_are_stable() {
let cases = [
diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs
@@ -7,6 +7,7 @@ use std::{
path::{Path, PathBuf},
};
+use nostr::{Keys, SecretKey};
use radroots_service_sqlite::{
MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database,
@@ -309,6 +310,76 @@ async fn interrupted_first_binding_resumes_after_schema_migration() {
connection.close().await.expect("connection close");
}
+#[tokio::test]
+async fn semantically_conflicting_but_structurally_valid_history_fails_closed() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let current = configuration(EXAMPLE);
+ let metadata = RhiStateMetadata::new(
+ &runtime,
+ ¤t,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata");
+ let (applied_at, build) = evidence(1_725_000_000);
+ initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+
+ let conflicting_key =
+ Keys::new(SecretKey::from_slice(&[0x33; 32]).expect("deterministic conflicting secret"))
+ .public_key()
+ .to_hex();
+ assert_ne!(conflicting_key, metadata.expected_identity().as_hex());
+ let mut connection = offline_connection(&runtime).await;
+ sqlx::query(
+ r#"INSERT INTO rhi_config_bindings (
+ generation, normalized_config_sha256, evidence_policy_sha256,
+ service_public_key, config_contract_version, state_contract_version,
+ admin_contract_version, status_contract_version, provider_contract_version,
+ applied_at_unix_s, service_version, service_commit, lib_revision,
+ rust_version, target, feature_profile
+ )
+ SELECT generation + 1, normalized_config_sha256, evidence_policy_sha256,
+ ?, config_contract_version, state_contract_version,
+ admin_contract_version, status_contract_version, provider_contract_version,
+ applied_at_unix_s + 1, service_version, service_commit, lib_revision,
+ rust_version, target, feature_profile
+ FROM rhi_config_bindings WHERE generation = 1"#,
+ )
+ .bind(conflicting_key)
+ .execute(&mut connection)
+ .await
+ .expect("append structurally valid conflicting evidence");
+ connection.close().await.expect("connection close");
+
+ let rejected = open_rhi_state_read_write_from_config(&runtime, ¤t, applied_at, &build)
+ .await
+ .expect_err("conflicting history must fail closed");
+ assert_eq!(rejected.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence);
+ let retried = open_rhi_state_read_write_from_config(&runtime, ¤t, applied_at, &build)
+ .await
+ .expect_err("rejected history must not leak writer authority");
+ assert_eq!(retried.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence);
+
+ let mut connection = offline_connection(&runtime).await;
+ assert_eq!(
+ sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings")
+ .fetch_one(&mut connection)
+ .await
+ .expect("history count"),
+ 2
+ );
+ connection.close().await.expect("connection close");
+}
+
#[test]
fn configuration_lifecycle_surface_is_sealed_and_redacted() {
assert!(HOST_SOURCE.contains("open_read_write_existing_with_intent"));
diff --git a/tests/services_hardening_runtime_foundation.rs b/tests/services_hardening_runtime_foundation.rs
@@ -264,7 +264,7 @@ async fn foundation_proves_state_config_identity_and_contacts_no_transport() {
}
#[tokio::test]
-async fn missing_or_mismatched_state_fails_before_identity_or_transport_access() {
+async fn missing_state_fails_before_identity_or_transport_access() {
let directory = tempfile::tempdir().expect("root");
let runtime = runtime(directory.path());
let secret = identity_secret();
@@ -294,6 +294,86 @@ async fn missing_or_mismatched_state_fails_before_identity_or_transport_access()
assert!(!runtime.artifacts().state_database().exists());
}
+#[tokio::test]
+async fn mismatched_configuration_fails_before_identity_or_transport_access() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ let secret = identity_secret();
+ let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let current = configuration(&runtime, &identity);
+ let metadata = prepare(&runtime, ¤t);
+ let (applied_at, build) = evidence();
+ initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+
+ let changed_source = CONFIG_EXAMPLE
+ .replace(
+ "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt",
+ runtime.identity_path().to_str().expect("identity path"),
+ )
+ .replace(&"2".repeat(64), &identity)
+ .replacen("level = \"info\"", "level = \"debug\"", 1);
+ let changed = parse_rhi_config_v1(changed_source.as_bytes(), RhiConfigProfile::RepoLocal)
+ .expect("changed configuration");
+ let spy = TransportSpy(Arc::new(AtomicUsize::new(0)));
+ let error =
+ open_rhi_runtime_foundation(runtime.clone(), changed, adapters(&spy), applied_at, &build)
+ .await
+ .expect_err("configuration mismatch");
+ assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::StateOpen);
+ assert_eq!(spy.invoked(), 0);
+
+ let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("failed foundation releases state authority");
+ reopened.close().await.expect("close");
+}
+
+#[tokio::test]
+async fn identity_failure_after_state_open_releases_authority_without_transport_access() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ let secret = identity_secret();
+ let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let configuration = configuration(&runtime, &identity);
+ let metadata = prepare(&runtime, &configuration);
+ let (applied_at, build) = evidence();
+ initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+ fs::remove_file(
+ runtime
+ .context()
+ .paths()
+ .secrets()
+ .join("service_wrapping_key"),
+ )
+ .expect("remove credential");
+
+ let spy = TransportSpy(Arc::new(AtomicUsize::new(0)));
+ let error = open_rhi_runtime_foundation(
+ runtime.clone(),
+ configuration,
+ adapters(&spy),
+ applied_at,
+ &build,
+ )
+ .await
+ .expect_err("missing credential");
+ assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::IdentityAccess);
+ assert_eq!(spy.invoked(), 0);
+
+ let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("identity failure releases state authority");
+ reopened.close().await.expect("close");
+}
+
#[test]
fn source_and_contract_keep_final_runtime_authority_deferred() {
let contract: serde_json::Value = serde_json::from_str(CONTRACT_SOURCE).expect("contract");
diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs
@@ -7,6 +7,7 @@ use std::{
num::NonZeroU64,
os::unix::fs::{MetadataExt, PermissionsExt},
path::{Path, PathBuf},
+ time::Duration,
};
use radroots_service_sqlite::{
@@ -96,6 +97,21 @@ fn recovery_paths(runtime: &rhi::RhiRuntimeContext) -> [PathBuf; 4] {
]
}
+fn directory_inventory(directory: &Path) -> Vec<String> {
+ let mut entries = fs::read_dir(directory)
+ .expect("state directory")
+ .map(|entry| {
+ entry
+ .expect("state entry")
+ .file_name()
+ .to_string_lossy()
+ .into_owned()
+ })
+ .collect::<Vec<_>>();
+ entries.sort();
+ entries
+}
+
#[tokio::test]
async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() {
let directory = tempfile::tempdir().expect("temporary root");
@@ -110,6 +126,36 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() {
let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
.await
.expect("writable host");
+ let cancelled_bundle = directory.path().join("cancelled-backup");
+ let cancelled = tokio::time::timeout(
+ Duration::from_nanos(1),
+ writer.capture_online_backup(
+ &cancelled_bundle,
+ BackupCreatedAtUnixMs::new(1_725_000_000_050).expect("capture time"),
+ ),
+ )
+ .await;
+ assert!(cancelled.is_err(), "capture future must be cancellable");
+ writer
+ .close()
+ .await
+ .expect("close drains cancelled capture cleanup");
+ assert!(!cancelled_bundle.exists());
+
+ let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("writer reacquisition after cancelled capture");
+ let cancelled_integrity = tokio::time::timeout(
+ Duration::from_nanos(1),
+ writer.inspect_integrity(
+ IntegrityCheckedAtUnixMs::new(1_725_000_000_099).expect("inspection time"),
+ ),
+ )
+ .await;
+ assert!(
+ cancelled_integrity.is_err(),
+ "integrity future must be cancellable"
+ );
let report = writer
.inspect_integrity(
IntegrityCheckedAtUnixMs::new(1_725_000_000_100).expect("inspection time"),
@@ -150,6 +196,13 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() {
let live_path = runtime.artifacts().state_database();
let old_live_inode = fs::metadata(live_path).expect("live metadata").ino();
+ let state_directory = runtime.context().paths().state();
+ let live_bytes_before_inspection = fs::read(live_path).expect("live bytes");
+ let live_modified_before_inspection = fs::metadata(live_path)
+ .expect("live metadata")
+ .modified()
+ .expect("live modified time");
+ let inventory_before_inspection = directory_inventory(state_directory);
let inspection = open_rhi_state_inspection(&runtime, &metadata)
.await
.expect("read-only inspection");
@@ -188,6 +241,21 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() {
.expect_err("offline staging rejects a live inspection host");
assert_eq!(contended.kind(), RhiStateMaintenanceErrorKind::Authority);
inspection.close().await.expect("inspection close");
+ assert_eq!(
+ fs::read(live_path).expect("live bytes after inspection"),
+ live_bytes_before_inspection
+ );
+ assert_eq!(
+ fs::metadata(live_path)
+ .expect("live metadata after inspection")
+ .modified()
+ .expect("live modified time after inspection"),
+ live_modified_before_inspection
+ );
+ assert_eq!(
+ directory_inventory(state_directory),
+ inventory_before_inspection
+ );
let verified = verify_rhi_state_backup(
&manifest_bytes,