Skip to main content

canic_core/api/ic/
call.rs

1//! Public IC call façade with optional intent-based concurrency control.
2//!
3//! This module defines the stable, public API used by application code to make
4//! inter-canister calls. It deliberately exposes a *thin* surface:
5//!
6//! - argument encoding
7//! - cycle attachment
8//! - optional intent declaration
9//!
10//! It does NOT:
11//! - perform orchestration itself
12//! - expose intent internals
13//! - leak workflow or storage details
14//!
15//! If an intent is attached to a call, the actual multi-step behavior
16//! (reserve → call → commit/abort) is handled by the workflow layer.
17//!
18//! This separation keeps application code simple while ensuring correctness
19//! under concurrency.
20use crate::{
21    cdk::{
22        candid::{CandidType, encode_one},
23        types::{BoundedString128, Principal},
24    },
25    config::schema::RoleAttestationConfig,
26    dto::{
27        auth::{
28            CanicInternalCallEnvelopeV1, CanicInternalCallHeaderV1, InternalInvocationProofRequest,
29            SignedInternalInvocationProofV1,
30        },
31        error::Error,
32        error::ErrorCode,
33    },
34    ids::CanisterRole,
35    ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
36    workflow::ic::call::{
37        CallBuilder as WorkflowCallBuilder, CallResult as WorkflowCallResult, CallWorkflow,
38        IntentSpec as WorkflowIntentSpec,
39    },
40};
41use candid::{
42    encode_args,
43    utils::{ArgumentDecoder, ArgumentEncoder},
44};
45use serde::de::DeserializeOwned;
46use std::{borrow::Cow, cell::RefCell, collections::BTreeMap};
47
48const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
49const INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS: u64 = 30;
50
51thread_local! {
52    static INTERNAL_INVOCATION_PROOF_CACHE:
53        RefCell<BTreeMap<InternalInvocationProofCacheKey, SignedInternalInvocationProofV1>> =
54        const { RefCell::new(BTreeMap::new()) };
55}
56
57///
58/// Call
59///
60/// Entry point for constructing inter-canister calls.
61///
62/// `Call` itself has no state; it simply selects the wait semantics
63/// (bounded vs unbounded) and produces a `CallBuilder`.
64///
65/// Think of this as the *verb* (“make a call”), not the call itself.
66///
67
68pub struct Call;
69
70impl Call {
71    #[must_use]
72    pub fn bounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
73        CallBuilder {
74            inner: CallWorkflow::bounded_wait(canister_id, method),
75        }
76    }
77
78    #[must_use]
79    pub fn unbounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
80        CallBuilder {
81            inner: CallWorkflow::unbounded_wait(canister_id, method),
82        }
83    }
84}
85
86///
87/// CanicCall
88///
89/// Low-level protected Canic internal-call primitive.
90///
91/// Unlike `Call`, this API is only for Canic-to-Canic protected internal
92/// endpoints. It obtains a root-signed method-scoped invocation proof, wraps
93/// the original Candid arguments in `CanicInternalCallEnvelopeV1`, and dispatches
94/// through the raw call path.
95///
96
97pub struct CanicCall;
98
99impl CanicCall {
100    #[must_use]
101    pub fn bounded_wait(
102        canister_id: impl Into<Principal>,
103        method: &str,
104    ) -> CanicCallBuilder<'static> {
105        CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
106    }
107
108    #[must_use]
109    pub fn unbounded_wait(
110        canister_id: impl Into<Principal>,
111        method: &str,
112    ) -> CanicCallBuilder<'static> {
113        CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
114    }
115}
116
117///
118/// ProtectedInternalEndpoint
119///
120/// Generated metadata for one protected Canic internal endpoint.
121///
122/// Endpoint macros emit this descriptor next to protected internal endpoints.
123/// Callers should pass it to `CanicInternalClient` instead of repeating method
124/// names and accepted-role metadata by hand.
125///
126
127#[derive(Clone, Debug)]
128pub struct ProtectedInternalEndpoint {
129    method: &'static str,
130    accepted_roles: Vec<CanisterRole>,
131}
132
133impl ProtectedInternalEndpoint {
134    #[must_use]
135    pub fn new(method: &'static str, roles: impl IntoIterator<Item = CanisterRole>) -> Self {
136        Self {
137            method,
138            accepted_roles: roles.into_iter().collect(),
139        }
140    }
141
142    #[must_use]
143    pub const fn method(&self) -> &'static str {
144        self.method
145    }
146
147    #[must_use]
148    pub fn accepted_roles(&self) -> &[CanisterRole] {
149        &self.accepted_roles
150    }
151
152    #[must_use]
153    pub fn accepts_role(&self, role: &CanisterRole) -> bool {
154        self.accepted_roles.iter().any(|accepted| accepted == role)
155    }
156
157    #[must_use]
158    pub fn single_role(&self) -> Option<&CanisterRole> {
159        match self.accepted_roles.as_slice() {
160            [role] => Some(role),
161            _ => None,
162        }
163    }
164
165    pub fn required_single_role(&self) -> Result<CanisterRole, Error> {
166        self.single_role().cloned().ok_or_else(|| {
167            Error::invalid(format!(
168                "protected internal endpoint '{}' accepts {} roles; choose a caller role explicitly",
169                self.method(),
170                self.accepted_roles.len()
171            ))
172        })
173    }
174}
175
176///
177/// CanicInternalClient
178///
179/// Generic protected internal client over generated endpoint descriptors.
180///
181
182#[derive(Clone, Copy, Debug)]
183pub struct CanicInternalClient {
184    canister_id: Principal,
185}
186
187impl CanicInternalClient {
188    #[must_use]
189    pub const fn new(canister_id: Principal) -> Self {
190        Self { canister_id }
191    }
192
193    pub async fn call_update<A>(
194        &self,
195        endpoint: &ProtectedInternalEndpoint,
196        caller_role: CanisterRole,
197        args: A,
198    ) -> Result<CallResult, Error>
199    where
200        A: ArgumentEncoder,
201    {
202        if !endpoint.accepts_role(&caller_role) {
203            return Err(Error::invalid(format!(
204                "caller role '{caller_role}' is not accepted by protected internal endpoint '{}'",
205                endpoint.method()
206            )));
207        }
208
209        CanicCall::unbounded_wait(self.canister_id, endpoint.method())
210            .with_caller_role(caller_role)
211            .with_args(args)?
212            .execute()
213            .await
214    }
215
216    pub async fn call_update_with_single_role<A>(
217        &self,
218        endpoint: &ProtectedInternalEndpoint,
219        args: A,
220    ) -> Result<CallResult, Error>
221    where
222        A: ArgumentEncoder,
223    {
224        let role = endpoint.required_single_role()?;
225        self.call_update(endpoint, role, args).await
226    }
227
228    pub async fn call_update_result<T, A>(
229        &self,
230        endpoint: &ProtectedInternalEndpoint,
231        caller_role: CanisterRole,
232        args: A,
233    ) -> Result<T, Error>
234    where
235        T: CandidType + DeserializeOwned,
236        A: ArgumentEncoder,
237    {
238        let call = self.call_update(endpoint, caller_role, args).await?;
239        let result: Result<T, Error> = call.candid()?;
240        result
241    }
242
243    pub async fn call_update_result_with_single_role<T, A>(
244        &self,
245        endpoint: &ProtectedInternalEndpoint,
246        args: A,
247    ) -> Result<T, Error>
248    where
249        T: CandidType + DeserializeOwned,
250        A: ArgumentEncoder,
251    {
252        let role = endpoint.required_single_role()?;
253        self.call_update_result(endpoint, role, args).await
254    }
255}
256
257///
258/// CanicCallBuilder
259///
260
261pub struct CanicCallBuilder<'a> {
262    wait: WaitMode,
263    canister_id: Principal,
264    method: String,
265    caller_role: Option<CanisterRole>,
266    ttl_secs: Option<u64>,
267    cycles: u128,
268    args: Cow<'a, [u8]>,
269}
270
271impl CanicCallBuilder<'_> {
272    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
273        Self {
274            wait,
275            canister_id,
276            method: method.to_string(),
277            caller_role: None,
278            ttl_secs: None,
279            cycles: 0,
280            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
281        }
282    }
283
284    #[must_use]
285    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
286        self.caller_role = Some(role);
287        self
288    }
289
290    #[must_use]
291    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
292        self.ttl_secs = Some(ttl_secs);
293        self
294    }
295
296    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
297    where
298        A: CandidType,
299    {
300        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
301        Ok(self)
302    }
303
304    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
305    where
306        A: ArgumentEncoder,
307    {
308        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
309        Ok(self)
310    }
311
312    #[must_use]
313    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
314        CanicCallBuilder {
315            wait: self.wait,
316            canister_id: self.canister_id,
317            method: self.method,
318            caller_role: self.caller_role,
319            ttl_secs: self.ttl_secs,
320            cycles: self.cycles,
321            args: args.into(),
322        }
323    }
324
325    #[must_use]
326    pub const fn with_cycles(mut self, cycles: u128) -> Self {
327        self.cycles = cycles;
328        self
329    }
330
331    pub async fn execute(self) -> Result<CallResult, Error> {
332        let ttl_secs = self.proof_ttl_secs()?;
333        let role = self
334            .caller_role
335            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
336        let request = InternalInvocationProofRequest {
337            subject: IcOps::canister_self(),
338            role,
339            subnet_id: EnvOps::subnet_pid().ok(),
340            audience: self.canister_id,
341            audience_method: self.method.clone(),
342            ttl_secs,
343            metadata: None,
344        };
345        let args = self.args.into_owned();
346        let proof = internal_invocation_proof_for_request(request.clone()).await?;
347
348        let envelope =
349            build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
350        let result = execute_internal_call_once(
351            self.wait,
352            self.canister_id,
353            &self.method,
354            self.cycles,
355            envelope,
356        )
357        .await?;
358        if !internal_call_result_is_retryable(&result) {
359            return Ok(result);
360        }
361
362        invalidate_internal_invocation_proof(&request)?;
363        let proof = fresh_internal_invocation_proof_for_request(request).await?;
364        let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
365        execute_internal_call_once(
366            self.wait,
367            self.canister_id,
368            &self.method,
369            self.cycles,
370            envelope,
371        )
372        .await
373    }
374
375    fn proof_ttl_secs(&self) -> Result<u64, Error> {
376        let requested = self
377            .ttl_secs
378            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
379        let max = ConfigOps::role_attestation_config()
380            .map_err(Error::from)?
381            .max_ttl_secs;
382        Ok(requested.min(max))
383    }
384}
385
386async fn execute_internal_call_once(
387    wait: WaitMode,
388    canister_id: Principal,
389    method: &str,
390    cycles: u128,
391    envelope: CanicInternalCallEnvelopeV1,
392) -> Result<CallResult, Error> {
393    let call = match wait {
394        WaitMode::Bounded => Call::bounded_wait(canister_id, method),
395        WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
396    }
397    .with_cycles(cycles)
398    .with_arg(envelope)?;
399
400    call.execute().await
401}
402
403///
404/// InternalInvocationProofCacheKey
405///
406
407#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
408struct InternalInvocationProofCacheKey {
409    root_pid: Principal,
410    attestation_key_name: String,
411    subject: Principal,
412    role: CanisterRole,
413    subnet_id: Option<Principal>,
414    audience: Principal,
415    audience_method: String,
416    ttl_secs: u64,
417}
418
419async fn internal_invocation_proof_for_request(
420    request: InternalInvocationProofRequest,
421) -> Result<SignedInternalInvocationProofV1, Error> {
422    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
423    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
424    let now_secs = IcOps::now_secs();
425
426    if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
427        return Ok(proof);
428    }
429
430    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
431}
432
433async fn fresh_internal_invocation_proof_for_request(
434    request: InternalInvocationProofRequest,
435) -> Result<SignedInternalInvocationProofV1, Error> {
436    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
437    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
438    let now_secs = IcOps::now_secs();
439    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
440}
441
442async fn fresh_internal_invocation_proof_for_request_with_context(
443    request: InternalInvocationProofRequest,
444    cfg: RoleAttestationConfig,
445    root_pid: Principal,
446    now_secs: u64,
447) -> Result<SignedInternalInvocationProofV1, Error> {
448    let proof =
449        crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
450    cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
451    Ok(proof)
452}
453
454fn internal_invocation_proof_cache_key(
455    request: &InternalInvocationProofRequest,
456    cfg: &RoleAttestationConfig,
457    root_pid: Principal,
458) -> InternalInvocationProofCacheKey {
459    InternalInvocationProofCacheKey {
460        root_pid,
461        attestation_key_name: cfg.ecdsa_key_name.clone(),
462        subject: request.subject,
463        role: request.role.clone(),
464        subnet_id: request.subnet_id,
465        audience: request.audience,
466        audience_method: request.audience_method.clone(),
467        ttl_secs: request.ttl_secs,
468    }
469}
470
471fn cached_internal_invocation_proof(
472    request: &InternalInvocationProofRequest,
473    cfg: &RoleAttestationConfig,
474    root_pid: Principal,
475    now_secs: u64,
476) -> Option<SignedInternalInvocationProofV1> {
477    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
478    let min_accepted_epoch = cfg
479        .min_accepted_epoch_by_role
480        .get(request.role.as_str())
481        .copied()
482        .unwrap_or(0);
483
484    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
485        let proof = cache.get(&key)?;
486        if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
487            Some(proof.clone())
488        } else {
489            cache.remove(&key);
490            None
491        }
492    })
493}
494
495fn cache_internal_invocation_proof(
496    request: &InternalInvocationProofRequest,
497    cfg: &RoleAttestationConfig,
498    root_pid: Principal,
499    now_secs: u64,
500    proof: SignedInternalInvocationProofV1,
501) {
502    let min_accepted_epoch = cfg
503        .min_accepted_epoch_by_role
504        .get(request.role.as_str())
505        .copied()
506        .unwrap_or(0);
507    if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
508        return;
509    }
510
511    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
512    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
513        cache.insert(key, proof);
514    });
515}
516
517fn invalidate_internal_invocation_proof(
518    request: &InternalInvocationProofRequest,
519) -> Result<(), Error> {
520    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
521    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
522    let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
523    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
524        cache.remove(&key);
525    });
526    Ok(())
527}
528
529fn internal_invocation_proof_is_reusable(
530    proof: &SignedInternalInvocationProofV1,
531    request: &InternalInvocationProofRequest,
532    now_secs: u64,
533    min_accepted_epoch: u64,
534) -> bool {
535    let payload = &proof.payload;
536    payload.subject == request.subject
537        && payload.role == request.role
538        && payload.subnet_id == request.subnet_id
539        && payload.audience == request.audience
540        && payload.audience_method == request.audience_method
541        && payload.epoch >= min_accepted_epoch
542        && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
543            < payload.expires_at
544}
545
546fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
547    proof
548        .payload
549        .expires_at
550        .saturating_sub(proof.payload.issued_at)
551        .saturating_div(5)
552        .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
553}
554
555fn internal_call_result_is_retryable(result: &CallResult) -> bool {
556    let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
557        return false;
558    };
559    internal_call_error_is_retryable(&err)
560}
561
562const fn internal_call_error_is_retryable(err: &Error) -> bool {
563    matches!(
564        err.code,
565        ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
566    )
567}
568
569#[derive(Clone, Copy, Debug, Eq, PartialEq)]
570enum WaitMode {
571    Bounded,
572    Unbounded,
573}
574
575fn build_internal_call_envelope(
576    target_canister: Principal,
577    target_method: &str,
578    proof: SignedInternalInvocationProofV1,
579    args: Vec<u8>,
580) -> CanicInternalCallEnvelopeV1 {
581    CanicInternalCallEnvelopeV1 {
582        version: 1,
583        header: CanicInternalCallHeaderV1 {
584            target_canister,
585            target_method: target_method.to_string(),
586        },
587        proof,
588        args,
589    }
590}
591
592///
593/// IntentKey
594///
595/// Stable, bounded identifier for a contended resource.
596///
597/// An intent key names *what is being reserved*, not how the reservation
598/// is enforced. Keys are opaque strings with a fixed maximum length
599/// to ensure safe storage and indexing.
600///
601/// Examples:
602/// - "vendor:abc123:inventory"
603/// - "collection:xyz:mint"
604///
605
606pub struct IntentKey(BoundedString128);
607
608impl IntentKey {
609    pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
610        BoundedString128::try_new(value)
611            .map(Self)
612            .map_err(Error::invalid)
613    }
614
615    #[must_use]
616    pub fn as_str(&self) -> &str {
617        self.0.as_str()
618    }
619
620    #[must_use]
621    pub fn into_inner(self) -> BoundedString128 {
622        self.0
623    }
624}
625
626impl AsRef<str> for IntentKey {
627    fn as_ref(&self) -> &str {
628        self.0.as_str()
629    }
630}
631
632impl From<IntentKey> for BoundedString128 {
633    fn from(key: IntentKey) -> Self {
634        key.0
635    }
636}
637
638///
639/// IntentReservation
640///
641/// Declarative reservation attached to a call.
642///
643/// An intent expresses *preconditions* for executing a call, such as:
644/// - how much of a resource is required (`quantity`)
645/// - how long the reservation may remain pending (`ttl_secs`)
646/// - optional concurrency caps (`max_in_flight`)
647///
648/// Importantly:
649/// - An intent is **single-shot**
650/// - Failed intents are not reused
651/// - Retrying requires creating a new intent
652///
653/// The reservation itself is enforced by the workflow layer.
654///
655
656pub struct IntentReservation {
657    key: IntentKey,
658    quantity: u64,
659    ttl_secs: Option<u64>,
660    max_in_flight: Option<u64>,
661}
662
663impl IntentReservation {
664    #[must_use]
665    pub const fn new(key: IntentKey, quantity: u64) -> Self {
666        Self {
667            key,
668            quantity,
669            ttl_secs: None,
670            max_in_flight: None,
671        }
672    }
673
674    #[must_use]
675    pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
676        self.ttl_secs = Some(ttl_secs);
677        self
678    }
679
680    #[must_use]
681    pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
682        self.max_in_flight = Some(max_in_flight);
683        self
684    }
685
686    pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
687        WorkflowIntentSpec::new(
688            self.key.into(),
689            self.quantity,
690            self.ttl_secs,
691            self.max_in_flight,
692        )
693    }
694}
695
696///
697/// CallBuilder (api)
698///
699
700pub struct CallBuilder<'a> {
701    inner: WorkflowCallBuilder<'a>,
702}
703
704impl CallBuilder<'_> {
705    // ---------- arguments ----------
706
707    /// Encode a single argument into Candid bytes (fallible).
708    pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
709    where
710        A: CandidType,
711    {
712        Ok(Self {
713            inner: self.inner.with_arg(arg).map_err(Error::from)?,
714        })
715    }
716
717    /// Encode multiple arguments into Candid bytes (fallible).
718    pub fn with_args<A>(self, args: A) -> Result<Self, Error>
719    where
720        A: ArgumentEncoder,
721    {
722        Ok(Self {
723            inner: self.inner.with_args(args).map_err(Error::from)?,
724        })
725    }
726
727    /// Use pre-encoded Candid arguments (no validation performed).
728    #[must_use]
729    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
730        CallBuilder {
731            inner: self.inner.with_raw_args(args),
732        }
733    }
734
735    // ---------- cycles ----------
736
737    #[must_use]
738    pub fn with_cycles(self, cycles: u128) -> Self {
739        Self {
740            inner: self.inner.with_cycles(cycles),
741        }
742    }
743
744    // ---------- intent ----------
745
746    #[must_use]
747    pub fn with_intent(self, intent: IntentReservation) -> Self {
748        Self {
749            inner: self.inner.with_intent(intent.into_spec()),
750        }
751    }
752
753    // ---------- execution ----------
754
755    pub async fn execute(self) -> Result<CallResult, Error> {
756        Ok(CallResult {
757            inner: self.inner.execute().await.map_err(Error::from)?,
758        })
759    }
760}
761
762///
763/// CallResult
764///
765/// Stable wrapper around an inter-canister call response.
766///
767/// This type exists to:
768/// - decouple API consumers from infra response types
769/// - provide uniform decoding helpers
770/// - allow future extension without breaking callers
771///
772
773pub struct CallResult {
774    inner: WorkflowCallResult,
775}
776
777impl CallResult {
778    pub fn candid<R>(&self) -> Result<R, Error>
779    where
780        R: CandidType + DeserializeOwned,
781    {
782        self.inner.candid().map_err(Error::from)
783    }
784
785    pub fn candid_tuple<R>(&self) -> Result<R, Error>
786    where
787        R: for<'de> ArgumentDecoder<'de>,
788    {
789        self.inner.candid_tuple().map_err(Error::from)
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use crate::config::schema::RoleAttestationConfig;
797    use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
798    use candid::decode_args;
799    use std::collections::BTreeMap;
800
801    fn p(id: u8) -> Principal {
802        Principal::from_slice(&[id; 29])
803    }
804
805    fn proof() -> SignedInternalInvocationProofV1 {
806        SignedInternalInvocationProofV1 {
807            payload: InternalInvocationProofPayloadV1 {
808                subject: p(1),
809                role: CanisterRole::new("project_hub"),
810                subnet_id: None,
811                audience: p(2),
812                audience_method: "system_add_project_to_user".to_string(),
813                issued_at: 10,
814                expires_at: 20,
815                epoch: 3,
816            },
817            signature: vec![1, 2, 3],
818            key_id: 1,
819        }
820    }
821
822    fn request() -> InternalInvocationProofRequest {
823        InternalInvocationProofRequest {
824            subject: p(1),
825            role: CanisterRole::new("project_hub"),
826            subnet_id: Some(p(9)),
827            audience: p(2),
828            audience_method: "system_add_project_to_user".to_string(),
829            ttl_secs: 120,
830            metadata: None,
831        }
832    }
833
834    fn cfg(min_epoch: u64) -> RoleAttestationConfig {
835        let mut min_accepted_epoch_by_role = BTreeMap::new();
836        min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
837        RoleAttestationConfig {
838            ecdsa_key_name: "key_1".to_string(),
839            max_ttl_secs: 900,
840            min_accepted_epoch_by_role,
841        }
842    }
843
844    fn clear_internal_invocation_proof_cache() {
845        INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
846    }
847
848    #[test]
849    fn canic_call_envelope_binds_target_method_and_original_args() {
850        let args = encode_args((7_u64, "project")).expect("args encode");
851        let envelope =
852            build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
853
854        assert_eq!(envelope.version, 1);
855        assert_eq!(envelope.header.target_canister, p(2));
856        assert_eq!(envelope.header.target_method, "system_add_project_to_user");
857        assert_eq!(
858            envelope.proof.payload.audience_method,
859            "system_add_project_to_user"
860        );
861
862        let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
863        assert_eq!(decoded, (7, "project".to_string()));
864    }
865
866    #[test]
867    fn canic_call_builder_records_role_and_raw_args() {
868        let raw = vec![9_u8, 8, 7];
869        let builder = CanicCall::unbounded_wait(p(3), "target")
870            .with_caller_role(CanisterRole::new("project_hub"))
871            .with_proof_ttl_secs(30)
872            .with_cycles(10)
873            .with_raw_args(raw.clone());
874
875        assert_eq!(builder.wait, WaitMode::Unbounded);
876        assert_eq!(builder.canister_id, p(3));
877        assert_eq!(builder.method, "target");
878        assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
879        assert_eq!(builder.ttl_secs, Some(30));
880        assert_eq!(builder.cycles, 10);
881        assert_eq!(builder.args.as_ref(), raw.as_slice());
882    }
883
884    #[test]
885    fn protected_internal_endpoint_descriptor_matches_roles() {
886        let endpoint = ProtectedInternalEndpoint::new(
887            "system_add_project_to_user",
888            [
889                CanisterRole::new("project_hub"),
890                CanisterRole::new("admin_hub"),
891            ],
892        );
893
894        assert_eq!(endpoint.method(), "system_add_project_to_user");
895        assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
896        assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
897        assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
898        assert!(endpoint.single_role().is_none());
899    }
900
901    #[test]
902    fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
903        let endpoint = ProtectedInternalEndpoint::new(
904            "system_add_project_to_user",
905            [CanisterRole::new("project_hub")],
906        );
907
908        assert_eq!(
909            endpoint.single_role(),
910            Some(&CanisterRole::new("project_hub"))
911        );
912        assert_eq!(
913            endpoint.required_single_role().expect("single role"),
914            CanisterRole::new("project_hub")
915        );
916    }
917
918    #[test]
919    fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
920        let endpoint = ProtectedInternalEndpoint::new(
921            "system_add_project_to_user",
922            [
923                CanisterRole::new("project_hub"),
924                CanisterRole::new("admin_hub"),
925            ],
926        );
927
928        let err = endpoint
929            .required_single_role()
930            .expect_err("multi-role endpoint should require explicit caller role");
931        assert_eq!(err.code, ErrorCode::InvalidInput);
932    }
933
934    #[test]
935    fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
936        clear_internal_invocation_proof_cache();
937        let request = request();
938        let mut proof = proof();
939        proof.payload.subnet_id = request.subnet_id;
940        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
941
942        let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
943            .expect("fresh matching proof should cache-hit");
944
945        assert_eq!(cached, proof);
946    }
947
948    #[test]
949    fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
950        clear_internal_invocation_proof_cache();
951        let request = request();
952        let mut proof = proof();
953        proof.payload.subnet_id = request.subnet_id;
954        proof.payload.issued_at = 10;
955        proof.payload.expires_at = 20;
956        cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
957
958        assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
959    }
960
961    #[test]
962    fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
963        clear_internal_invocation_proof_cache();
964        let request = request();
965        let mut proof = proof();
966        proof.payload.subnet_id = request.subnet_id;
967        proof.payload.epoch = 3;
968        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
969
970        assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
971    }
972
973    #[test]
974    fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
975        assert!(internal_call_error_is_retryable(&Error::new(
976            ErrorCode::AuthKeyUnknown,
977            "unknown key".to_string(),
978        )));
979        assert!(internal_call_error_is_retryable(&Error::new(
980            ErrorCode::AuthMaterialStale,
981            "stale epoch".to_string(),
982        )));
983        assert!(!internal_call_error_is_retryable(&Error::new(
984            ErrorCode::AuthProofExpired,
985            "expired".to_string(),
986        )));
987        assert!(!internal_call_error_is_retryable(&Error::unauthorized(
988            "role mismatch"
989        )));
990    }
991}