use super::{fixtures::*, *};
use crate::cache_file::{CacheFileError, HostCacheError};
use crate::nns::{LiveNnsSource, NnsSourceRequest};
#[cfg(unix)]
use std::os::unix::fs::symlink;
use std::{
future::Future,
sync::atomic::{AtomicUsize, Ordering},
task::{Context, Poll},
};
struct PendingSource;
impl SubnetCatalogSource for PendingSource {
fn fetch_catalog<'a>(
&'a self,
_request: &'a NnsSourceRequest,
) -> SubnetCatalogSourceFuture<'a> {
Box::pin(std::future::pending())
}
}
struct WrongEndpointSource;
impl SubnetCatalogSource for WrongEndpointSource {
fn fetch_catalog<'a>(
&'a self,
_request: &'a NnsSourceRequest,
) -> SubnetCatalogSourceFuture<'a> {
Box::pin(async { Ok(fixture_catalog()) })
}
}
#[test]
fn refresh_exports_protect_a_live_sources_separate_history_root_before_collection() {
let root = temp_dir("ic-query-catalog-separate-history-export");
let history_root = temp_dir("ic-query-catalog-source-history-export");
let source =
LiveSubnetCatalogSource::with_progress(|_| panic!("export rejected before collection"))
.with_history_cache(&history_root);
let paths: [_; 2] = source
.history_cache_paths()
.expect("configured history paths")
.into();
for output in paths {
crate::cache_file::write_managed_text_atomically(
&history_root,
output,
"preserved evidence",
)
.expect("seed protected bytes");
let mut request = refresh_request(&root);
request.output_path = Some(output.to_path_buf());
for dry_run in [true, false] {
request.dry_run = dry_run;
let error = refresh_subnet_catalog_with_source(&request, &source)
.expect_err("source-owned history export alias");
assert!(matches!(
error,
SubnetCatalogHostError::Cache(HostCacheError::Operation {
source: CacheFileError::OutputAliasesManagedPath { .. },
..
})
));
assert_eq!(
fs::read_to_string(output).expect("protected bytes"),
"preserved evidence"
);
assert!(!subnet_catalog_refresh_lock_path(&root, "ic").exists());
}
}
assert!(!root.exists());
fs::remove_dir_all(history_root).expect("cleanup history root");
}
#[test]
fn live_catalog_source_rejects_non_mainnet_before_agent_construction() {
let request = NnsSourceRequest::new(
"local",
"not a valid replica endpoint",
"2026-07-29T00:00:00Z",
"test",
);
let error = crate::runtime::block_on_current_thread(LiveNnsSource.fetch_catalog(&request))
.expect("test runtime")
.expect_err("unsupported network");
assert!(matches!(
error,
SubnetCatalogHostError::UnsupportedNetwork { network } if network == "local"
));
}
#[test]
fn public_async_catalog_fetch_rejects_non_mainnet_before_agent_construction() {
let request = NnsSourceRequest::new(
"local",
"not a valid replica endpoint",
"2026-07-29T00:00:00Z",
"test",
);
let error = crate::runtime::block_on_current_thread(fetch_subnet_catalog_async(&request))
.expect("test runtime")
.expect_err("unsupported network");
assert!(matches!(
error,
SubnetCatalogHostError::UnsupportedNetwork { network } if network == "local"
));
}
#[test]
fn refresh_writes_catalog_atomically_and_removes_lock() {
let root = temp_dir("ic-query-subnet-refresh");
let mut catalog = fixture_catalog();
catalog.provenance.registry_version = 987_654;
catalog.provenance.fetched_at = "1970-01-01T00:00:00Z".to_string();
catalog.provenance.source_endpoints = vec![DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string()];
catalog.canonicalize_and_seal().expect("reseal fixture");
let source = FixtureRefreshSource::ok(catalog);
let request = refresh_request(&root);
let report = refresh_subnet_catalog_with_source(&request, &source).expect("refresh catalog");
let cached =
load_cached_subnet_catalog(&cache_only_load_request(&root)).expect("cached catalog");
let lock_path = PathBuf::from(&report.refresh_lock_path);
assert_eq!(
fs::read(&report.catalog_path).expect("read published catalog bytes"),
catalog_to_pretty_json(cached.catalog.raw())
.expect("pretty catalog JSON")
.into_bytes(),
);
let _ = fs::remove_dir_all(root);
assert!(report.wrote_catalog);
assert!(!report.replaced_existing_catalog);
assert_eq!(report.registry_version, 987_654);
assert_eq!(report.assurance, CatalogAssurance::UncertifiedQuery);
assert!(report.agreement_digest.is_none());
assert_eq!(report.registry_query_call_count, 5);
assert_eq!(
report.source_endpoints,
vec![DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string()]
);
assert_eq!(report.catalog_digest, cached.catalog.raw().catalog_digest);
assert_eq!(cached.catalog.provenance().registry_version, 987_654);
assert!(!lock_path.exists());
}
#[test]
fn async_multi_endpoint_refresh_publishes_matching_agreement_evidence() {
let root = temp_dir("ic-query-subnet-refresh-agreement");
let alpha = "https://alpha.example";
let beta = "https://beta.example";
let source = AgreementFixtureSource::new(AgreementFixtureMode::Matching, beta);
let mut request = refresh_request(&root);
request.source =
CatalogSourceSelection::multi_endpoint_agreement(vec![beta.to_string(), alpha.to_string()]);
let report =
futures::executor::block_on(refresh_subnet_catalog_with_source_async(&request, &source))
.expect("matching endpoints refresh");
let cached =
load_cached_subnet_catalog(&cache_only_load_request(&root)).expect("agreement cache");
assert_eq!(source.call_count(), 2);
assert_eq!(report.assurance, CatalogAssurance::MultiEndpointAgreement);
assert_eq!(
report.source_endpoints,
vec![alpha.to_string(), beta.to_string()]
);
assert_eq!(report.registry_query_call_count, 10);
assert_eq!(report.registry_records.len(), 8);
assert!(report.registry_records.iter().all(|evidence| {
evidence.requested_registry_version == report.registry_version
&& report.source_endpoints.contains(&evidence.source_endpoint)
}));
assert_eq!(
report.agreement_digest,
cached.catalog.provenance().agreement_digest
);
assert!(report.agreement_digest.is_some());
assert_eq!(
cached.catalog.provenance().assurance,
CatalogAssurance::MultiEndpointAgreement
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn detailed_agreement_mismatch_retains_every_completed_endpoint_record() {
let root = temp_dir("ic-query-subnet-detailed-agreement-mismatch");
let alpha = "https://alpha.example";
let beta = "https://beta.example";
let source = AgreementFixtureSource::new(AgreementFixtureMode::PayloadMismatch, beta);
let request = SubnetCatalogLoadRequest::cache_only(cache_request(&root), 1_780_531_300)
.with_policy(CatalogReadPolicy::ForceRefresh {
source: CatalogSourceSelection::multi_endpoint_agreement(vec![
alpha.to_string(),
beta.to_string(),
]),
});
let failure = futures::executor::block_on(load_subnet_catalog_detailed_with_source_async(
&request, &source,
))
.expect_err("mismatched endpoint evidence");
assert_eq!(failure.stage, SubnetCatalogLoadStage::RefreshFailed);
assert_eq!(failure.registry_records.len(), 8);
assert!(
failure.registry_records.iter().all(|evidence| {
evidence.source_endpoint == alpha || evidence.source_endpoint == beta
})
);
assert!(!subnet_catalog_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn agreement_rejects_version_and_payload_mismatch_without_publishing() {
for (name, mode) in [
("version", AgreementFixtureMode::VersionMismatch),
("payload", AgreementFixtureMode::PayloadMismatch),
] {
let root = temp_dir(&format!("ic-query-subnet-agreement-{name}"));
write_catalog(&root, fixture_catalog());
let beta = "https://beta.example";
let source = AgreementFixtureSource::new(mode, beta);
let mut request = refresh_request(&root);
request.source = CatalogSourceSelection::multi_endpoint_agreement(vec![
"https://alpha.example".to_string(),
beta.to_string(),
]);
let error = futures::executor::block_on(refresh_subnet_catalog_with_source_async(
&request, &source,
))
.expect_err("mismatched endpoint evidence");
let cached =
load_cached_subnet_catalog(&cache_only_load_request(&root)).expect("original cache");
assert!(matches!(
error,
SubnetCatalogHostError::AgreementMismatch { endpoint, .. } if endpoint == beta
));
assert_eq!(cached.catalog.provenance().registry_version, 123_456);
assert!(!subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
}
#[test]
fn invalid_agreement_selection_is_rejected_before_source_or_cache_io() {
for endpoints in [
vec![
"https://same.example".to_string(),
"https://same.example:8443".to_string(),
],
vec!["https://only.example".to_string()],
vec![
"https://one.example".to_string(),
"https://two.example".to_string(),
"https://three.example".to_string(),
"https://four.example".to_string(),
],
] {
let root = temp_dir("ic-query-subnet-invalid-agreement");
let source =
AgreementFixtureSource::new(AgreementFixtureMode::Matching, "https://none.example");
let mut request = refresh_request(&root);
request.source = CatalogSourceSelection::multi_endpoint_agreement(endpoints);
let error = futures::executor::block_on(refresh_subnet_catalog_with_source_async(
&request, &source,
))
.expect_err("invalid selection");
assert!(matches!(
error,
SubnetCatalogHostError::InvalidSourceSelection { .. }
));
assert_eq!(source.call_count(), 0);
assert!(!subnet_catalog_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
}
#[test]
fn agreement_endpoint_failure_keeps_exact_endpoint_context() {
let root = temp_dir("ic-query-subnet-agreement-endpoint-error");
let beta = "https://beta.example";
let source = AgreementFixtureSource::new(AgreementFixtureMode::EndpointFailure, beta);
let mut request = refresh_request(&root);
request.source = CatalogSourceSelection::multi_endpoint_agreement(vec![
"https://alpha.example".to_string(),
beta.to_string(),
]);
let error =
futures::executor::block_on(refresh_subnet_catalog_with_source_async(&request, &source))
.expect_err("endpoint fails");
assert!(matches!(
error,
SubnetCatalogHostError::AgreementEndpoint { endpoint, .. } if endpoint == beta
));
assert_eq!(source.call_count(), 2);
assert!(!subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn cancelled_async_refresh_drops_its_owned_lock_without_publishing() {
let root = temp_dir("ic-query-subnet-refresh-cancelled");
let request = refresh_request(&root);
let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
let mut future = Box::pin(refresh_subnet_catalog_with_source_async(
&request,
&PendingSource,
));
let waker = futures::task::noop_waker();
let mut context = Context::from_waker(&waker);
assert!(matches!(future.as_mut().poll(&mut context), Poll::Pending));
assert!(lock_path.exists());
drop(future);
assert!(!lock_path.exists());
assert!(!subnet_catalog_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn custom_source_must_echo_the_exact_requested_endpoint() {
let root = temp_dir("ic-query-subnet-source-endpoint-mismatch");
let requested = "https://other.example";
let mut request = refresh_request(&root);
request.source = CatalogSourceSelection::uncertified_query(requested);
let error = futures::executor::block_on(refresh_subnet_catalog_with_source_async(
&request,
&WrongEndpointSource,
))
.expect_err("source endpoint must match request");
assert!(matches!(
error,
SubnetCatalogHostError::SourceEvidenceMismatch {
requested: actual_request,
actual_endpoints,
..
} if actual_request == requested
&& actual_endpoints == [DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT]
));
assert!(!subnet_catalog_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn refresh_rejects_output_aliasing_catalog_without_changing_cache() {
for dry_run in [true, false] {
let root = temp_dir("ic-query-subnet-refresh-output-alias");
let mut original_catalog = fixture_catalog();
original_catalog.provenance.fetched_by = "original-cache".to_string();
original_catalog
.canonicalize_and_seal()
.expect("seal original");
write_catalog(&root, original_catalog);
let path = subnet_catalog_path(&root, MAINNET_NETWORK);
let original = fs::read(&path).expect("original catalog");
let request = refresh_request(&root)
.with_dry_run(dry_run)
.with_output_path(&path);
let result = refresh_subnet_catalog_with_source(
&request,
&FixtureRefreshSource::ok(fixture_catalog()),
);
assert!(matches!(
result,
Err(SubnetCatalogHostError::Cache(HostCacheError::Operation {
source: CacheFileError::OutputAliasesManagedPath { .. },
..
}))
));
assert_eq!(fs::read(&path).expect("preserved catalog"), original);
assert!(!subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
}
#[test]
fn refresh_rejects_output_aliases_before_fetching_or_creating_catalog_and_lock() {
let root = temp_dir("ic-query-subnet-refresh-output-preflight");
for output_path in [
subnet_catalog_path(&root, MAINNET_NETWORK),
subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK),
crate::subnet_catalog::subnet_catalog_history_path(&root, MAINNET_NETWORK),
crate::subnet_catalog::subnet_catalog_history_lock_path(&root, MAINNET_NETWORK),
] {
let request = refresh_request(&root)
.with_dry_run(true)
.with_output_path(output_path);
let error = refresh_subnet_catalog_with_source(&request, &FixtureRefreshSource::err())
.expect_err("invalid output fails before the failing source is called");
assert!(matches!(
error,
SubnetCatalogHostError::Cache(HostCacheError::Operation {
source: CacheFileError::OutputAliasesManagedPath { .. },
..
})
));
assert!(!root.exists());
}
}
#[cfg(unix)]
#[test]
fn refresh_rechecks_output_aliases_after_source_collection() {
struct AliasingSource {
output_path: PathBuf,
catalog_path: PathBuf,
fixture: FixtureRefreshSource,
hard_link: bool,
}
impl SubnetCatalogSource for AliasingSource {
fn fetch_catalog<'a>(
&'a self,
request: &'a NnsSourceRequest,
) -> SubnetCatalogSourceFuture<'a> {
Box::pin(async move {
if self.hard_link {
fs::hard_link(&self.catalog_path, &self.output_path).expect("late hard link");
} else {
symlink(&self.catalog_path, &self.output_path).expect("late symlink");
}
self.fixture.fetch_catalog(request).await
})
}
}
for hard_link in [true, false] {
let root = temp_dir("ic-query-subnet-refresh-late-output-alias");
write_catalog(&root, fixture_catalog());
let catalog_path = subnet_catalog_path(&root, MAINNET_NETWORK);
let original = fs::read(&catalog_path).expect("original catalog");
let output_path = root.join("export.json");
let source = AliasingSource {
output_path: output_path.clone(),
catalog_path: catalog_path.clone(),
fixture: FixtureRefreshSource::ok(fixture_catalog()),
hard_link,
};
let request = refresh_request(&root)
.with_dry_run(true)
.with_output_path(output_path);
let error = refresh_subnet_catalog_with_source(&request, &source)
.expect_err("alias introduced during collection is rejected");
assert!(matches!(
error,
SubnetCatalogHostError::Cache(HostCacheError::Operation {
source: CacheFileError::OutputAliasesManagedPath { .. },
..
})
));
assert_eq!(
fs::read(&catalog_path).expect("preserved catalog"),
original
);
assert!(!subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK).exists());
let _ = fs::remove_dir_all(root);
}
}
#[test]
fn refresh_dry_run_writes_output_without_replacing_cache() {
let root = temp_dir("ic-query-subnet-refresh-dry-run");
let mut catalog = fixture_catalog();
catalog.provenance.fetched_at = "1970-01-01T00:00:00Z".to_string();
catalog.provenance.source_endpoints = vec![DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string()];
catalog.canonicalize_and_seal().expect("reseal fixture");
let output_path = root.join("catalog-export.json");
let source = FixtureRefreshSource::ok(catalog);
let mut request = refresh_request(&root);
request.dry_run = true;
request.output_path = Some(output_path.clone());
let report = refresh_subnet_catalog_with_source(&request, &source).expect("dry-run");
assert!(!report.wrote_catalog);
assert!(!subnet_catalog_path(&request.cache.cache_root, MAINNET_NETWORK).exists());
assert!(output_path.exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn refresh_dry_run_distinct_output_preserves_existing_catalog_bytes() {
let root = temp_dir("ic-query-subnet-refresh-dry-run-existing");
let mut original_catalog = fixture_catalog();
original_catalog.provenance.fetched_by = "original-cache".to_string();
original_catalog
.canonicalize_and_seal()
.expect("seal original");
write_catalog(&root, original_catalog);
let path = subnet_catalog_path(&root, MAINNET_NETWORK);
let original = fs::read(&path).expect("original catalog");
let output_path = root.join("catalog-export.json");
let request = refresh_request(&root)
.with_dry_run(true)
.with_output_path(&output_path);
let report =
refresh_subnet_catalog_with_source(&request, &FixtureRefreshSource::ok(fixture_catalog()))
.expect("distinct dry-run output");
assert!(!report.wrote_catalog);
assert!(report.replaced_existing_catalog);
assert_eq!(fs::read(&path).expect("preserved catalog"), original);
assert_ne!(fs::read(&output_path).expect("export"), original);
let _ = fs::remove_dir_all(root);
}
#[test]
fn refresh_failure_preserves_existing_catalog_and_removes_lock() {
let root = temp_dir("ic-query-subnet-refresh-failure");
write_catalog(&root, fixture_catalog());
let source = FixtureRefreshSource::err();
let request = refresh_request(&root);
let err = refresh_subnet_catalog_with_source(&request, &source).expect_err("refresh fails");
let cached =
load_cached_subnet_catalog(&cache_only_load_request(&root)).expect("cached catalog");
let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
assert!(matches!(
err,
SubnetCatalogHostError::Catalog(CatalogError::EmptySubnets)
));
assert_eq!(cached.catalog.provenance().registry_version, 123_456);
assert!(!lock_path.exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn refresh_existing_fresh_lock_fails_fast() {
let root = temp_dir("ic-query-subnet-refresh-locked");
let request = refresh_request(&root);
let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
write_refresh_lock_for_test(&lock_path, &request, request.now_unix_secs * 1_000);
let err = refresh_subnet_catalog_with_source(&request, &FixtureRefreshSource::err())
.expect_err("lock held");
let _ = fs::remove_dir_all(root);
assert!(matches!(
err,
SubnetCatalogHostError::Cache(HostCacheError::Operation {
source: CacheFileError::RefreshAlreadyInProgress { .. },
..
})
));
}
#[test]
fn refresh_rejects_stale_lock_without_removing_it() {
let root = temp_dir("ic-query-subnet-refresh-stale-lock");
let mut catalog = fixture_catalog();
catalog.provenance.fetched_at = "1970-01-01T00:00:00Z".to_string();
catalog.provenance.source_endpoints = vec![DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string()];
catalog.canonicalize_and_seal().expect("reseal fixture");
let source = FixtureRefreshSource::ok(catalog);
let request = refresh_request(&root);
let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
let stale_started_at = (request.now_unix_secs - request.lock_stale_after_seconds - 1) * 1_000;
write_refresh_lock_for_test(&lock_path, &request, stale_started_at);
let err = refresh_subnet_catalog_with_source(&request, &source)
.expect_err("stale lock requires manual cleanup");
assert!(matches!(
err,
SubnetCatalogHostError::Cache(HostCacheError::Operation {
source: CacheFileError::StaleRefreshLock { .. },
..
})
));
assert!(lock_path.exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn concurrent_endpoint_cancellation_preserves_cache_and_allows_immediate_retry() {
struct TrackedSource {
started: AtomicUsize,
dropped: AtomicUsize,
}
struct Guard<'a>(&'a AtomicUsize);
impl Drop for Guard<'_> {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
impl SubnetCatalogSource for TrackedSource {
fn fetch_catalog<'a>(&'a self, _: &'a NnsSourceRequest) -> SubnetCatalogSourceFuture<'a> {
Box::pin(async move {
self.started.fetch_add(1, Ordering::SeqCst);
let _guard = Guard(&self.dropped);
std::future::pending().await
})
}
}
let root = temp_dir("ic-query-concurrent-cancel");
write_catalog(&root, fixture_catalog());
let path = subnet_catalog_path(&root, MAINNET_NETWORK);
let before = fs::read(&path).unwrap();
let mut request = refresh_request(&root);
request.source = CatalogSourceSelection::multi_endpoint_agreement(vec![
"https://a.example".into(),
"https://b.example".into(),
"https://c.example".into(),
]);
let source = TrackedSource {
started: AtomicUsize::new(0),
dropped: AtomicUsize::new(0),
};
let mut future = Box::pin(refresh_subnet_catalog_with_source_async(&request, &source));
let waker = futures::task::noop_waker();
assert!(
future
.as_mut()
.poll(&mut Context::from_waker(&waker))
.is_pending()
);
assert_eq!(source.started.load(Ordering::SeqCst), 2);
drop(future);
assert_eq!(source.dropped.load(Ordering::SeqCst), 2);
assert_eq!(fs::read(&path).unwrap(), before);
assert!(!subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK).exists());
let retry = AgreementFixtureSource::new(AgreementFixtureMode::Matching, "https://b.example");
futures::executor::block_on(refresh_subnet_catalog_with_source_async(&request, &retry))
.unwrap();
assert_eq!(retry.call_count(), 3);
let _ = fs::remove_dir_all(root);
}