ic-query 0.25.2

Internet Computer query library for NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
use super::*;
use crate::{
    nns::{
        NnsSourceRequest,
        topology::report::subnet_topology::NNS_SUBNET_TOPOLOGY_REPORT_SCHEMA_VERSION,
    },
    subnet_catalog::{MAINNET_NETWORK, MAINNET_REGISTRY_CANISTER_ID, SubnetKind},
    test_support::temp_dir,
};
use candid::Principal;
use std::{cell::Cell, fs, path::PathBuf};

const NOW: u64 = 1_700_000_000;

#[test]
fn report_validation_enforces_canonical_counts_and_order() {
    let report = fixture_report(42, "2023-11-14T22:13:20Z");
    report.validate().expect("valid report");

    let mut mismatched = report.clone();
    mismatched.subnets[0].node_count += 1;
    assert!(matches!(
        mismatched.validate().expect_err("count mismatch"),
        NnsSubnetTopologyValidationError::SubnetNodeCountMismatch { .. }
    ));

    let mut unordered = report;
    unordered.subnets.reverse();
    assert!(matches!(
        unordered.validate().expect_err("order mismatch"),
        NnsSubnetTopologyValidationError::NonCanonicalSubnetOrder { .. }
    ));
}

#[test]
fn refresh_holds_one_lock_and_publishes_one_validated_snapshot() {
    let root = temp_dir("ic-query-subnet-topology-refresh");
    let request = refresh_request(&root, NOW);
    let lock_path = nns_subnet_topology_refresh_lock_path(&root, MAINNET_NETWORK);
    let source = FixtureSource::new(42, Some(lock_path.clone()));

    let cached =
        refresh_nns_subnet_topology_with_source(&request, &source).expect("refresh snapshot");

    assert_eq!(source.calls.get(), 1);
    assert_eq!(cached.report.registry_version, 42);
    assert_eq!(
        cached.path,
        nns_subnet_topology_cache_path(&root, MAINNET_NETWORK)
    );
    assert!(!lock_path.exists());
    let loaded = load_cached_nns_subnet_topology(&request.cache).expect("load snapshot");
    assert_eq!(loaded.report, cached.report);
    let _ = fs::remove_dir_all(root);
}

#[test]
fn missing_and_stale_refresh_policies_are_explicitly_distinct() {
    let root = temp_dir("ic-query-subnet-topology-policies");
    let old_request = refresh_request(&root, NOW);
    refresh_nns_subnet_topology_with_source(&old_request, &FixtureSource::new(1, None))
        .expect("initial refresh");

    let later_request = refresh_request(&root, NOW + 100);
    let missing_source = FixtureSource::new(2, None);
    let loaded =
        load_or_refresh_missing_nns_subnet_topology_with_source(&later_request, &missing_source)
            .expect("existing cache");
    assert_eq!(loaded.report.registry_version, 1);
    assert_eq!(missing_source.calls.get(), 0);

    let stale_source = FixtureSource::new(2, None);
    let refreshed =
        load_or_refresh_stale_nns_subnet_topology_with_source(&later_request, 10, &stale_source)
            .expect("stale refresh");
    assert_eq!(refreshed.report.registry_version, 2);
    assert_eq!(stale_source.calls.get(), 1);

    let fresh_source = FixtureSource::new(3, None);
    let fresh =
        load_or_refresh_stale_nns_subnet_topology_with_source(&later_request, 10, &fresh_source)
            .expect("fresh cache");
    assert_eq!(fresh.report.registry_version, 2);
    assert_eq!(fresh_source.calls.get(), 0);
    let _ = fs::remove_dir_all(root);
}

#[test]
fn invalid_refresh_preserves_the_last_complete_snapshot() {
    let root = temp_dir("ic-query-subnet-topology-invalid-refresh");
    let request = refresh_request(&root, NOW);
    refresh_nns_subnet_topology_with_source(&request, &FixtureSource::new(1, None))
        .expect("initial refresh");

    let invalid_source = InvalidFixtureSource;
    let error = refresh_nns_subnet_topology_with_source(&request, &invalid_source)
        .expect_err("invalid report");
    assert!(matches!(
        error,
        NnsSubnetTopologyHostError::Validation(
            NnsSubnetTopologyValidationError::ZeroNodeProviderCount { .. }
        )
    ));

    let loaded = load_cached_nns_subnet_topology(&request.cache).expect("old snapshot");
    assert_eq!(loaded.report.registry_version, 1);
    let _ = fs::remove_dir_all(root);
}

#[test]
fn cache_load_is_local_only_and_missing_is_typed() {
    let root = temp_dir("ic-query-subnet-topology-missing");
    let request = NnsSubnetTopologyCacheRequest::new(&root, MAINNET_NETWORK);

    let error = load_cached_nns_subnet_topology(&request).expect_err("missing cache");

    assert!(matches!(
        error,
        NnsSubnetTopologyHostError::Cache(crate::HostCacheError::MissingCache { path, .. })
            if path == nns_subnet_topology_cache_path(&root, MAINNET_NETWORK)
    ));
}

#[test]
fn text_preserves_raw_subnet_kind_and_provider_counts() {
    let report = fixture_report(42, "2023-11-14T22:13:20Z");

    let text = nns_subnet_topology_report_text(&report);

    assert!(text.contains("cloud_engine"));
    assert!(text.contains("PROVIDER_NODES"));
    assert!(text.contains("registry_version 42"));
}

struct FixtureSource {
    registry_version: u64,
    expected_lock_path: Option<PathBuf>,
    calls: Cell<usize>,
}

impl FixtureSource {
    const fn new(registry_version: u64, expected_lock_path: Option<PathBuf>) -> Self {
        Self {
            registry_version,
            expected_lock_path,
            calls: Cell::new(0),
        }
    }
}

impl NnsSubnetTopologySource for FixtureSource {
    fn fetch_subnet_topology_report(
        &self,
        request: &NnsSourceRequest,
    ) -> Result<NnsSubnetTopologyReport, NnsSubnetTopologyHostError> {
        self.calls.set(self.calls.get() + 1);
        if let Some(lock_path) = &self.expected_lock_path {
            assert!(
                lock_path.is_file(),
                "source fetch must run under the cache lock"
            );
        }
        Ok(fixture_report(self.registry_version, &request.fetched_at))
    }
}

struct InvalidFixtureSource;

impl NnsSubnetTopologySource for InvalidFixtureSource {
    fn fetch_subnet_topology_report(
        &self,
        request: &NnsSourceRequest,
    ) -> Result<NnsSubnetTopologyReport, NnsSubnetTopologyHostError> {
        let mut report = fixture_report(2, &request.fetched_at);
        report.subnets[0].node_providers[0].node_count = 0;
        Ok(report)
    }
}

fn refresh_request(root: &std::path::Path, now_unix_secs: u64) -> NnsSubnetTopologyRefreshRequest {
    NnsSubnetTopologyRefreshRequest::new(
        NnsSubnetTopologyCacheRequest::new(root, MAINNET_NETWORK),
        DEFAULT_NNS_SUBNET_TOPOLOGY_SOURCE_ENDPOINT,
        now_unix_secs,
        DEFAULT_NNS_SUBNET_TOPOLOGY_REFRESH_LOCK_STALE_SECONDS,
    )
}

fn fixture_report(registry_version: u64, fetched_at: &str) -> NnsSubnetTopologyReport {
    let mut subnet_principals = principals(&["subnet-a", "subnet-b"]);
    subnet_principals.sort();
    let mut provider_principals = principals(&["provider-a", "provider-b"]);
    provider_principals.sort();
    NnsSubnetTopologyReport {
        schema_version: NNS_SUBNET_TOPOLOGY_REPORT_SCHEMA_VERSION,
        network: MAINNET_NETWORK.to_string(),
        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
        registry_version,
        fetched_at: fetched_at.to_string(),
        source_endpoint: DEFAULT_NNS_SUBNET_TOPOLOGY_SOURCE_ENDPOINT.to_string(),
        fetched_by: "fixture".to_string(),
        subnet_count: 2,
        node_count: 3,
        subnets: vec![
            NnsSubnetTopologyRow {
                subnet_principal: subnet_principals[0].clone(),
                subnet_kind: SubnetKind::CloudEngine,
                node_count: 2,
                node_providers: vec![
                    NnsSubnetNodeProviderRow {
                        node_provider_principal: provider_principals[0].clone(),
                        node_count: 1,
                    },
                    NnsSubnetNodeProviderRow {
                        node_provider_principal: provider_principals[1].clone(),
                        node_count: 1,
                    },
                ],
            },
            NnsSubnetTopologyRow {
                subnet_principal: subnet_principals[1].clone(),
                subnet_kind: SubnetKind::System,
                node_count: 1,
                node_providers: vec![NnsSubnetNodeProviderRow {
                    node_provider_principal: provider_principals[1].clone(),
                    node_count: 1,
                }],
            },
        ],
    }
}

fn principals(labels: &[&str]) -> Vec<String> {
    labels
        .iter()
        .map(|label| Principal::self_authenticating(label.as_bytes()).to_text())
        .collect()
}