canic-core 0.54.0

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
use super::proof_cache::{
    cache_internal_invocation_proof, cached_internal_invocation_proof,
    clear_internal_invocation_proof_cache,
};
use super::*;
use crate::{
    config::schema::RoleAttestationConfig,
    dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1},
};
use candid::{decode_args, decode_one};
use std::collections::BTreeMap;

fn p(id: u8) -> Principal {
    Principal::from_slice(&[id; 29])
}

fn proof() -> SignedInternalInvocationProofV1 {
    SignedInternalInvocationProofV1 {
        payload: InternalInvocationProofPayloadV1 {
            subject: p(1),
            role: CanisterRole::new("project_hub"),
            subnet_id: None,
            audience: p(2),
            audience_method: "system_add_project_to_user".to_string(),
            issued_at: 10,
            expires_at: 20,
            epoch: 3,
        },
        signature: vec![1, 2, 3],
        key_id: 1,
    }
}

fn request() -> InternalInvocationProofRequest {
    InternalInvocationProofRequest {
        subject: p(1),
        role: CanisterRole::new("project_hub"),
        subnet_id: Some(p(9)),
        audience: p(2),
        audience_method: "system_add_project_to_user".to_string(),
        ttl_secs: 120,
        metadata: None,
    }
}

fn cfg(min_epoch: u64) -> RoleAttestationConfig {
    let mut min_accepted_epoch_by_role = BTreeMap::new();
    min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
    RoleAttestationConfig {
        ecdsa_key_name: "key_1".to_string(),
        max_ttl_secs: 900,
        min_accepted_epoch_by_role,
    }
}

#[test]
fn canic_call_envelope_binds_target_method_and_original_args() {
    let args = encode_args((7_u64, "project")).expect("args encode");
    let envelope = build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);

    assert_eq!(envelope.version, 1);
    assert_eq!(envelope.header.target_canister, p(2));
    assert_eq!(envelope.header.target_method, "system_add_project_to_user");
    assert_eq!(
        envelope.proof.payload.audience_method,
        "system_add_project_to_user"
    );

    let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
    assert_eq!(decoded, (7, "project".to_string()));
}

#[test]
fn canic_call_encodes_envelope_as_raw_ingress_bytes() {
    let args = encode_args((7_u64, "project")).expect("args encode");
    let envelope = build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
    let raw = encode_internal_call_envelope_raw(envelope.clone()).expect("envelope should encode");

    let decoded: CanicInternalCallEnvelopeV1 =
        decode_one(&raw).expect("raw ingress bytes decode as envelope");

    assert_eq!(decoded, envelope);
}

#[test]
fn canic_call_builder_records_role_and_raw_args() {
    let raw = vec![9_u8, 8, 7];
    let builder = CanicCall::unbounded_wait(p(3), "target")
        .with_caller_role(CanisterRole::new("project_hub"))
        .with_proof_ttl_secs(30)
        .with_cycles(10)
        .with_raw_args(raw.clone());

    assert_eq!(builder.wait, WaitMode::Unbounded);
    assert_eq!(builder.canister_id, p(3));
    assert_eq!(builder.method, "target");
    assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
    assert_eq!(builder.ttl_secs, Some(30));
    assert_eq!(builder.cycles, 10);
    assert_eq!(builder.args.as_ref(), raw.as_slice());
}

#[test]
fn canic_call_rejects_empty_target_method_locally() {
    let err = validate_internal_call_target_method("   ")
        .expect_err("empty protected call method should fail locally");

    assert_eq!(err.code, ErrorCode::InvalidInput);
}

#[test]
fn canic_call_rejects_empty_caller_role_locally() {
    let err = validate_internal_call_caller_role(&CanisterRole::new("   "))
        .expect_err("empty protected call role should fail locally");

    assert_eq!(err.code, ErrorCode::InvalidInput);
}

#[test]
fn canic_call_rejects_zero_effective_proof_ttl_locally() {
    let zero_requested = effective_internal_call_proof_ttl_secs(0, 900)
        .expect_err("zero requested proof ttl should fail locally");
    assert_eq!(zero_requested.code, ErrorCode::InvalidInput);

    let zero_max = effective_internal_call_proof_ttl_secs(120, 0)
        .expect_err("zero configured max proof ttl should fail locally");
    assert_eq!(zero_max.code, ErrorCode::InvalidInput);
}

#[test]
fn canic_call_clamps_requested_proof_ttl_to_config_max() {
    assert_eq!(
        effective_internal_call_proof_ttl_secs(120, 900).expect("ttl"),
        120
    );
    assert_eq!(
        effective_internal_call_proof_ttl_secs(1200, 900).expect("ttl"),
        900
    );
}

#[test]
fn protected_internal_endpoint_descriptor_matches_roles() {
    let endpoint = ProtectedInternalEndpoint::new(
        "system_add_project_to_user",
        [
            CanisterRole::new("project_hub"),
            CanisterRole::new("admin_hub"),
        ],
    );

    assert_eq!(endpoint.method(), "system_add_project_to_user");
    assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
    assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
    assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
    assert!(endpoint.single_role().is_none());
}

#[test]
fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
    let endpoint = ProtectedInternalEndpoint::new(
        "system_add_project_to_user",
        [CanisterRole::new("project_hub")],
    );

    assert_eq!(
        endpoint.single_role(),
        Some(&CanisterRole::new("project_hub"))
    );
    assert_eq!(
        endpoint.required_single_role().expect("single role"),
        CanisterRole::new("project_hub")
    );
}

#[test]
fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
    let endpoint = ProtectedInternalEndpoint::new(
        "system_add_project_to_user",
        [
            CanisterRole::new("project_hub"),
            CanisterRole::new("admin_hub"),
        ],
    );

    let err = endpoint
        .required_single_role()
        .expect_err("multi-role endpoint should require explicit caller role");
    assert_eq!(err.code, ErrorCode::InvalidInput);
}

#[test]
fn protected_internal_endpoint_descriptor_rejects_missing_method() {
    let result =
        std::panic::catch_unwind(|| ProtectedInternalEndpoint::new("", [CanisterRole::ROOT]));

    assert!(result.is_err());
}

#[test]
fn protected_internal_endpoint_descriptor_rejects_blank_method() {
    let result =
        std::panic::catch_unwind(|| ProtectedInternalEndpoint::new("   ", [CanisterRole::ROOT]));

    assert!(result.is_err());
}

#[test]
fn protected_internal_endpoint_descriptor_rejects_missing_roles() {
    let result = std::panic::catch_unwind(|| {
        ProtectedInternalEndpoint::new("system_add_project_to_user", [])
    });

    assert!(result.is_err());
}

#[test]
fn protected_internal_endpoint_descriptor_rejects_empty_role() {
    let result = std::panic::catch_unwind(|| {
        ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("")])
    });

    assert!(result.is_err());
}

#[test]
fn protected_internal_endpoint_descriptor_rejects_blank_role() {
    let result = std::panic::catch_unwind(|| {
        ProtectedInternalEndpoint::new("system_add_project_to_user", [CanisterRole::new("   ")])
    });

    assert!(result.is_err());
}

#[test]
fn protected_internal_endpoint_descriptor_rejects_duplicate_roles() {
    let result = std::panic::catch_unwind(|| {
        ProtectedInternalEndpoint::new(
            "system_add_project_to_user",
            [
                CanisterRole::new("project_hub"),
                CanisterRole::new("project_hub"),
            ],
        )
    });

    assert!(result.is_err());
}

#[test]
fn internal_client_options_are_chainable() {
    let client = CanicInternalClient::new(p(3))
        .with_bounded_wait()
        .with_cycles(10)
        .with_proof_ttl_secs(30);

    assert_eq!(client.canister_id, p(3));
    assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
    assert_eq!(client.options.cycles, 10);
    assert_eq!(client.options.proof_ttl_secs, Some(30));
}

#[test]
fn internal_client_rejects_unaccepted_explicit_role_locally() {
    let client = CanicInternalClient::new(p(3));
    let endpoint = ProtectedInternalEndpoint::new(
        "system_add_project_to_user",
        [CanisterRole::new("project_hub")],
    );
    let result = futures::executor::block_on(client.call_update(
        &endpoint,
        CanisterRole::new("admin_hub"),
        (),
    ));

    match result {
        Err(err) => assert_eq!(err.code, ErrorCode::InvalidInput),
        Ok(_) => panic!("unaccepted caller role should fail before transport"),
    }
}

#[test]
fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
    clear_internal_invocation_proof_cache();
    let request = request();
    let mut proof = proof();
    proof.payload.subnet_id = request.subnet_id;
    cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());

    let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
        .expect("fresh matching proof should cache-hit");

    assert_eq!(cached, proof);
}

#[test]
fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
    clear_internal_invocation_proof_cache();
    let request = request();
    let mut proof = proof();
    proof.payload.subnet_id = request.subnet_id;
    proof.payload.issued_at = 10;
    proof.payload.expires_at = 20;
    cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);

    assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
}

#[test]
fn internal_invocation_proof_cache_rejects_future_issued_at_entry() {
    clear_internal_invocation_proof_cache();
    let request = request();
    let mut proof = proof();
    proof.payload.subnet_id = request.subnet_id;
    proof.payload.issued_at = 20;
    proof.payload.expires_at = 40;
    cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);

    assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 12).is_none());
}

#[test]
fn internal_invocation_proof_cache_rejects_invalid_time_window() {
    clear_internal_invocation_proof_cache();
    let request = request();
    let mut proof = proof();
    proof.payload.subnet_id = request.subnet_id;
    proof.payload.issued_at = 20;
    proof.payload.expires_at = 20;
    cache_internal_invocation_proof(&request, &cfg(0), p(7), 20, proof);

    assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 20).is_none());
}

#[test]
fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
    clear_internal_invocation_proof_cache();
    let request = request();
    let mut proof = proof();
    proof.payload.subnet_id = request.subnet_id;
    proof.payload.epoch = 3;
    cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);

    assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
}

#[test]
fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
    assert!(internal_call_error_is_retryable(&Error::new(
        ErrorCode::AuthKeyUnknown,
        "unknown key".to_string(),
    )));
    assert!(internal_call_error_is_retryable(&Error::new(
        ErrorCode::AuthMaterialStale,
        "stale epoch".to_string(),
    )));
    assert!(!internal_call_error_is_retryable(&Error::new(
        ErrorCode::AuthProofExpired,
        "expired".to_string(),
    )));
    assert!(!internal_call_error_is_retryable(&Error::unauthorized(
        "role mismatch"
    )));
}