mod acquisition;
mod source;
use super::*;
use crate::test_support::temp_dir;
use ic_query::subnet_catalog::{
CacheDisposition, CatalogReadPolicy, SubnetCatalogHostError, SubnetCatalogSource,
load_subnet_catalog_detailed_with_source,
};
use source::{Reply, Source};
use std::{fs, sync::atomic::Ordering, time::Duration};
const NOW: u64 = 1_780_531_200;
#[test]
fn freshness_boundary_refreshes_only_expired_catalog_and_preserves_snapshot_identity_on_hit() {
let root = temp_dir("canic-catalog-expiry");
let source = Source::new(Reply::Matching, 10);
let mut request = mainnet_subnet_catalog_load_request(&root, NOW);
let first = load_subnet_catalog_detailed_with_source(&request, &source).unwrap();
assert_eq!(first.disposition, CacheDisposition::RefreshedMissing);
assert_eq!(
source.calls.load(Ordering::Relaxed),
MAINNET_CATALOG_ENDPOINTS.len()
);
assert_eq!(
first.catalog.provenance().assurance,
CatalogAssurance::MultiEndpointAgreement
);
request.now_unix_secs += MAINNET_CATALOG_MAX_AGE_SECONDS;
let hit = load_subnet_catalog_detailed_with_source(&request, &source).unwrap();
assert_eq!(hit.disposition, CacheDisposition::CacheHit);
assert_eq!(first.snapshot_authority(), hit.snapshot_authority());
assert_eq!(
source.calls.load(Ordering::Relaxed),
MAINNET_CATALOG_ENDPOINTS.len()
);
let report = ops::observation(&hit, request.now_unix_secs);
assert_eq!(report.age_seconds, Some(MAINNET_CATALOG_MAX_AGE_SECONDS));
assert_eq!(report.cache_disposition, "cache_hit");
assert_eq!(
report.catalog_digest,
first.snapshot_authority().catalog_digest
);
request.now_unix_secs += 1;
let refreshed =
load_subnet_catalog_detailed_with_source(&request, &Source::new(Reply::Matching, 11))
.unwrap();
assert_eq!(refreshed.disposition, CacheDisposition::RefreshedStale);
assert_ne!(first.snapshot_authority(), refreshed.snapshot_authority());
assert_eq!(first.snapshot_authority().registry_version, 10);
assert_eq!(refreshed.snapshot_authority().registry_version, 11);
assert_eq!(
ops::observation(&refreshed, request.now_unix_secs).age_seconds,
Some(0)
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn agreement_failure_preserves_cache_and_retry_recovers_without_weaker_fallback() {
for reply in [
Reply::DifferentVersion,
Reply::DifferentPayload,
Reply::Unavailable,
] {
let root = temp_dir("canic-catalog-agreement-failure");
let mut request = mainnet_subnet_catalog_load_request(&root, NOW);
let initial =
load_subnet_catalog_detailed_with_source(&request, &Source::new(Reply::Matching, 10))
.unwrap();
let bytes = fs::read(&initial.path).unwrap();
request.now_unix_secs += MAINNET_CATALOG_MAX_AGE_SECONDS + 1;
let failure = load_subnet_catalog_detailed_with_source(&request, &Source::new(reply, 11))
.unwrap_err();
match reply {
Reply::DifferentVersion | Reply::DifferentPayload => assert!(matches!(
failure.source,
SubnetCatalogHostError::AgreementMismatch { .. }
)),
Reply::Unavailable => assert!(matches!(
failure.source,
SubnetCatalogHostError::AgreementEndpoint { .. }
)),
Reply::Matching => unreachable!(),
}
assert_eq!(fs::read(&initial.path).unwrap(), bytes);
let retained = load_cached_mainnet_subnet_catalog(&root, request.now_unix_secs).unwrap();
assert_eq!(retained.snapshot_authority(), initial.snapshot_authority());
assert_eq!(fs::read(&initial.path).unwrap(), bytes);
let evidence = SubnetCatalogLoadFailureEvidenceV1::from_preflight_failure(&failure);
assert_eq!(
evidence.source_kind,
Some(SubnetCatalogSourceKindV1::MultiEndpointAgreement)
);
assert_eq!(evidence.stage, SubnetCatalogLoadStageV1::RefreshFailed);
assert_eq!(
evidence.cache_disposition,
SubnetCatalogFailureCacheDispositionV1::RefreshFailed {
trigger: SubnetCatalogRefreshTriggerV1::Stale
}
);
let recovered =
load_subnet_catalog_detailed_with_source(&request, &Source::new(Reply::Matching, 11))
.unwrap();
assert_eq!(recovered.disposition, CacheDisposition::RefreshedStale);
assert_eq!(recovered.snapshot_authority().registry_version, 11);
fs::remove_dir_all(root).unwrap();
}
}
#[test]
fn insufficient_assurance_cache_is_rejected_and_new_generation_refreshes_it() {
let root = temp_dir("canic-catalog-assurance");
let weak = SubnetCatalogLoadRequest::refresh_missing_or_invalid(
mainnet_subnet_catalog_load_request(&root, NOW).cache,
CatalogSourceSelection::uncertified_query(MAINNET_CATALOG_ENDPOINTS[0]),
NOW,
);
let initial =
load_subnet_catalog_detailed_with_source(&weak, &Source::new(Reply::Matching, 10)).unwrap();
let bytes = fs::read(&initial.path).unwrap();
let failure = load_cached_mainnet_subnet_catalog(&root, NOW).unwrap_err();
assert_eq!(
failure.stage,
ic_query::subnet_catalog::SubnetCatalogLoadStage::CacheRejection
);
assert_eq!(fs::read(&initial.path).unwrap(), bytes);
let failure = load_mainnet_with_source(&root, NOW, &Source::new(Reply::Unavailable, 10), None)
.unwrap_err();
assert!(matches!(
*failure,
crate::subnet_catalog::acquisition::CatalogAcquisitionError::Load(failure) if matches!(failure.source, SubnetCatalogHostError::AgreementEndpoint { .. })
));
assert_eq!(fs::read(&initial.path).unwrap(), bytes);
let upgraded =
load_mainnet_with_source(&root, NOW, &Source::new(Reply::Matching, 10), None).unwrap();
assert_eq!(upgraded.disposition, CacheDisposition::ForcedRefresh);
assert_eq!(
upgraded.catalog.provenance().assurance,
CatalogAssurance::MultiEndpointAgreement
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn mainnet_load_request_freezes_source_and_minimum_assurance() {
let request = mainnet_subnet_catalog_load_request(Path::new("/tmp/canic-test"), 123);
assert_eq!(
request.minimum_assurance,
CatalogAssurance::MultiEndpointAgreement
);
assert_eq!(request.now_unix_secs, 123);
assert_eq!(request.cache.network, MAINNET_NETWORK);
assert_eq!(
request.cache.cache_root,
Path::new("/tmp/canic-test/.canic/ic-query")
);
assert_eq!(
request.policy,
CatalogReadPolicy::RefreshMissingInvalidOrOlderThan {
source: CatalogSourceSelection::multi_endpoint_agreement(
MAINNET_CATALOG_ENDPOINTS
.iter()
.map(|value| (*value).to_string())
.collect(),
),
max_age_seconds: MAINNET_CATALOG_MAX_AGE_SECONDS,
}
);
}
#[test]
fn mainnet_preflight_request_is_cache_only_and_read_only() {
let request = mainnet_subnet_catalog_cache_only_request(Path::new("/tmp/canic-test"), 123);
assert_eq!(
request.minimum_assurance,
CatalogAssurance::MultiEndpointAgreement
);
assert_eq!(request.now_unix_secs, 123);
assert_eq!(request.cache.network, MAINNET_NETWORK);
assert_eq!(
request.cache.cache_root,
Path::new("/tmp/canic-test/.canic/ic-query")
);
assert_eq!(request.policy, CatalogReadPolicy::CacheOnly);
}
#[test]
fn cache_failure_reaches_canic_as_complete_typed_pre_effect_evidence() {
let root = temp_dir("canic-subnet-catalog-detailed-failure");
fs::create_dir_all(&root).expect("create temporary ICP root");
let failure =
load_cached_mainnet_subnet_catalog(&root, 123).expect_err("missing cache must fail closed");
let evidence = SubnetCatalogLoadFailureEvidenceV1::from_preflight_failure(&failure);
fs::remove_dir_all(root).expect("remove temporary ICP root");
assert_eq!(evidence.network, MAINNET_NETWORK);
assert_eq!(evidence.source_kind, None);
assert!(evidence.source_endpoints.is_empty());
assert_eq!(evidence.stage, SubnetCatalogLoadStageV1::CacheAbsence);
assert_eq!(evidence.registry_version, None);
assert_eq!(evidence.returned_registry_value_version, None);
assert_eq!(evidence.source_endpoint, None);
assert_eq!(evidence.assurance, None);
assert!(evidence.registry_records.is_empty());
assert_eq!(
evidence.cache_disposition,
SubnetCatalogFailureCacheDispositionV1::CacheMissing
);
assert!(matches!(
evidence.subject,
Some(SubnetCatalogSubjectV1::CachePath { .. })
));
assert_eq!(evidence.code, "missing_catalog");
assert_eq!(evidence.category, "missing");
assert_eq!(
evidence.retryability,
SubnetCatalogRetryabilityV1::NotRetryable
);
assert!(!evidence.effects.build_started);
assert!(!evidence.effects.workspace_mutation_started);
assert!(!evidence.effects.ic_mutation_started);
}
fn load_mainnet_with_source(
icp_root: &Path,
now_unix_secs: u64,
source: &dyn SubnetCatalogSource,
progress: Option<&(dyn Fn(&view::CatalogAcquisitionProgress) + Sync)>,
) -> Result<CatalogLoadOutcome, Box<crate::subnet_catalog::acquisition::CatalogAcquisitionError>> {
let request = mainnet_subnet_catalog_load_request(icp_root, now_unix_secs);
crate::subnet_catalog::acquisition::load(
&request,
source,
progress,
Duration::from_secs(MAINNET_CATALOG_ACQUISITION_SECONDS),
)
}