1use 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
57pub 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
86pub 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#[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#[derive(Clone, Copy, Debug)]
183pub struct CanicInternalClient {
184 canister_id: Principal,
185 options: CanicInternalCallOptions,
186}
187
188impl CanicInternalClient {
189 #[must_use]
190 pub const fn new(canister_id: Principal) -> Self {
191 Self {
192 canister_id,
193 options: CanicInternalCallOptions::new(),
194 }
195 }
196
197 #[must_use]
198 pub const fn with_options(mut self, options: CanicInternalCallOptions) -> Self {
199 self.options = options;
200 self
201 }
202
203 #[must_use]
204 pub const fn with_bounded_wait(mut self) -> Self {
205 self.options = self.options.with_bounded_wait();
206 self
207 }
208
209 #[must_use]
210 pub const fn with_unbounded_wait(mut self) -> Self {
211 self.options = self.options.with_unbounded_wait();
212 self
213 }
214
215 #[must_use]
216 pub const fn with_cycles(mut self, cycles: u128) -> Self {
217 self.options = self.options.with_cycles(cycles);
218 self
219 }
220
221 #[must_use]
222 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
223 self.options = self.options.with_proof_ttl_secs(ttl_secs);
224 self
225 }
226
227 pub async fn call_update<A>(
228 &self,
229 endpoint: &ProtectedInternalEndpoint,
230 caller_role: CanisterRole,
231 args: A,
232 ) -> Result<CallResult, Error>
233 where
234 A: ArgumentEncoder,
235 {
236 if !endpoint.accepts_role(&caller_role) {
237 return Err(Error::invalid(format!(
238 "caller role '{caller_role}' is not accepted by protected internal endpoint '{}'",
239 endpoint.method()
240 )));
241 }
242
243 let builder = match self.options.wait {
244 CanicInternalWaitMode::Bounded => {
245 CanicCall::bounded_wait(self.canister_id, endpoint.method())
246 }
247 CanicInternalWaitMode::Unbounded => {
248 CanicCall::unbounded_wait(self.canister_id, endpoint.method())
249 }
250 };
251 let builder = builder
252 .with_caller_role(caller_role)
253 .with_cycles(self.options.cycles);
254 let builder = if let Some(ttl_secs) = self.options.proof_ttl_secs {
255 builder.with_proof_ttl_secs(ttl_secs)
256 } else {
257 builder
258 };
259
260 builder.with_args(args)?.execute().await
261 }
262
263 pub async fn call_update_with_single_role<A>(
264 &self,
265 endpoint: &ProtectedInternalEndpoint,
266 args: A,
267 ) -> Result<CallResult, Error>
268 where
269 A: ArgumentEncoder,
270 {
271 let role = endpoint.required_single_role()?;
272 self.call_update(endpoint, role, args).await
273 }
274
275 pub async fn call_update_result<T, A>(
276 &self,
277 endpoint: &ProtectedInternalEndpoint,
278 caller_role: CanisterRole,
279 args: A,
280 ) -> Result<T, Error>
281 where
282 T: CandidType + DeserializeOwned,
283 A: ArgumentEncoder,
284 {
285 let call = self.call_update(endpoint, caller_role, args).await?;
286 let result: Result<T, Error> = call.candid()?;
287 result
288 }
289
290 pub async fn call_update_result_with_single_role<T, A>(
291 &self,
292 endpoint: &ProtectedInternalEndpoint,
293 args: A,
294 ) -> Result<T, Error>
295 where
296 T: CandidType + DeserializeOwned,
297 A: ArgumentEncoder,
298 {
299 let role = endpoint.required_single_role()?;
300 self.call_update_result(endpoint, role, args).await
301 }
302}
303
304#[derive(Clone, Copy, Debug, Eq, PartialEq)]
311pub struct CanicInternalCallOptions {
312 wait: CanicInternalWaitMode,
313 cycles: u128,
314 proof_ttl_secs: Option<u64>,
315}
316
317impl CanicInternalCallOptions {
318 #[must_use]
319 pub const fn new() -> Self {
320 Self {
321 wait: CanicInternalWaitMode::Unbounded,
322 cycles: 0,
323 proof_ttl_secs: None,
324 }
325 }
326
327 #[must_use]
328 pub const fn with_bounded_wait(mut self) -> Self {
329 self.wait = CanicInternalWaitMode::Bounded;
330 self
331 }
332
333 #[must_use]
334 pub const fn with_unbounded_wait(mut self) -> Self {
335 self.wait = CanicInternalWaitMode::Unbounded;
336 self
337 }
338
339 #[must_use]
340 pub const fn with_cycles(mut self, cycles: u128) -> Self {
341 self.cycles = cycles;
342 self
343 }
344
345 #[must_use]
346 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
347 self.proof_ttl_secs = Some(ttl_secs);
348 self
349 }
350}
351
352impl Default for CanicInternalCallOptions {
353 fn default() -> Self {
354 Self::new()
355 }
356}
357
358#[derive(Clone, Copy, Debug, Eq, PartialEq)]
363pub enum CanicInternalWaitMode {
364 Bounded,
365 Unbounded,
366}
367
368pub struct CanicCallBuilder<'a> {
373 wait: WaitMode,
374 canister_id: Principal,
375 method: String,
376 caller_role: Option<CanisterRole>,
377 ttl_secs: Option<u64>,
378 cycles: u128,
379 args: Cow<'a, [u8]>,
380}
381
382impl CanicCallBuilder<'_> {
383 fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
384 Self {
385 wait,
386 canister_id,
387 method: method.to_string(),
388 caller_role: None,
389 ttl_secs: None,
390 cycles: 0,
391 args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
392 }
393 }
394
395 #[must_use]
396 pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
397 self.caller_role = Some(role);
398 self
399 }
400
401 #[must_use]
402 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
403 self.ttl_secs = Some(ttl_secs);
404 self
405 }
406
407 pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
408 where
409 A: CandidType,
410 {
411 self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
412 Ok(self)
413 }
414
415 pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
416 where
417 A: ArgumentEncoder,
418 {
419 self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
420 Ok(self)
421 }
422
423 #[must_use]
424 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
425 CanicCallBuilder {
426 wait: self.wait,
427 canister_id: self.canister_id,
428 method: self.method,
429 caller_role: self.caller_role,
430 ttl_secs: self.ttl_secs,
431 cycles: self.cycles,
432 args: args.into(),
433 }
434 }
435
436 #[must_use]
437 pub const fn with_cycles(mut self, cycles: u128) -> Self {
438 self.cycles = cycles;
439 self
440 }
441
442 pub async fn execute(self) -> Result<CallResult, Error> {
443 let ttl_secs = self.proof_ttl_secs()?;
444 let role = self
445 .caller_role
446 .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
447 let request = InternalInvocationProofRequest {
448 subject: IcOps::canister_self(),
449 role,
450 subnet_id: EnvOps::subnet_pid().ok(),
451 audience: self.canister_id,
452 audience_method: self.method.clone(),
453 ttl_secs,
454 metadata: None,
455 };
456 let args = self.args.into_owned();
457 let proof = internal_invocation_proof_for_request(request.clone()).await?;
458
459 let envelope =
460 build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
461 let result = execute_internal_call_once(
462 self.wait,
463 self.canister_id,
464 &self.method,
465 self.cycles,
466 envelope,
467 )
468 .await?;
469 if !internal_call_result_is_retryable(&result) {
470 return Ok(result);
471 }
472
473 invalidate_internal_invocation_proof(&request)?;
474 let proof = fresh_internal_invocation_proof_for_request(request).await?;
475 let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
476 execute_internal_call_once(
477 self.wait,
478 self.canister_id,
479 &self.method,
480 self.cycles,
481 envelope,
482 )
483 .await
484 }
485
486 fn proof_ttl_secs(&self) -> Result<u64, Error> {
487 let requested = self
488 .ttl_secs
489 .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
490 let max = ConfigOps::role_attestation_config()
491 .map_err(Error::from)?
492 .max_ttl_secs;
493 Ok(requested.min(max))
494 }
495}
496
497async fn execute_internal_call_once(
498 wait: WaitMode,
499 canister_id: Principal,
500 method: &str,
501 cycles: u128,
502 envelope: CanicInternalCallEnvelopeV1,
503) -> Result<CallResult, Error> {
504 let call = match wait {
505 WaitMode::Bounded => Call::bounded_wait(canister_id, method),
506 WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
507 }
508 .with_cycles(cycles)
509 .with_arg(envelope)?;
510
511 call.execute().await
512}
513
514#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
519struct InternalInvocationProofCacheKey {
520 root_pid: Principal,
521 attestation_key_name: String,
522 subject: Principal,
523 role: CanisterRole,
524 subnet_id: Option<Principal>,
525 audience: Principal,
526 audience_method: String,
527 ttl_secs: u64,
528}
529
530async fn internal_invocation_proof_for_request(
531 request: InternalInvocationProofRequest,
532) -> Result<SignedInternalInvocationProofV1, Error> {
533 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
534 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
535 let now_secs = IcOps::now_secs();
536
537 if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
538 return Ok(proof);
539 }
540
541 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
542}
543
544async fn fresh_internal_invocation_proof_for_request(
545 request: InternalInvocationProofRequest,
546) -> Result<SignedInternalInvocationProofV1, Error> {
547 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
548 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
549 let now_secs = IcOps::now_secs();
550 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
551}
552
553async fn fresh_internal_invocation_proof_for_request_with_context(
554 request: InternalInvocationProofRequest,
555 cfg: RoleAttestationConfig,
556 root_pid: Principal,
557 now_secs: u64,
558) -> Result<SignedInternalInvocationProofV1, Error> {
559 let proof =
560 crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
561 cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
562 Ok(proof)
563}
564
565fn internal_invocation_proof_cache_key(
566 request: &InternalInvocationProofRequest,
567 cfg: &RoleAttestationConfig,
568 root_pid: Principal,
569) -> InternalInvocationProofCacheKey {
570 InternalInvocationProofCacheKey {
571 root_pid,
572 attestation_key_name: cfg.ecdsa_key_name.clone(),
573 subject: request.subject,
574 role: request.role.clone(),
575 subnet_id: request.subnet_id,
576 audience: request.audience,
577 audience_method: request.audience_method.clone(),
578 ttl_secs: request.ttl_secs,
579 }
580}
581
582fn cached_internal_invocation_proof(
583 request: &InternalInvocationProofRequest,
584 cfg: &RoleAttestationConfig,
585 root_pid: Principal,
586 now_secs: u64,
587) -> Option<SignedInternalInvocationProofV1> {
588 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
589 let min_accepted_epoch = cfg
590 .min_accepted_epoch_by_role
591 .get(request.role.as_str())
592 .copied()
593 .unwrap_or(0);
594
595 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
596 let proof = cache.get(&key)?;
597 if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
598 Some(proof.clone())
599 } else {
600 cache.remove(&key);
601 None
602 }
603 })
604}
605
606fn cache_internal_invocation_proof(
607 request: &InternalInvocationProofRequest,
608 cfg: &RoleAttestationConfig,
609 root_pid: Principal,
610 now_secs: u64,
611 proof: SignedInternalInvocationProofV1,
612) {
613 let min_accepted_epoch = cfg
614 .min_accepted_epoch_by_role
615 .get(request.role.as_str())
616 .copied()
617 .unwrap_or(0);
618 if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
619 return;
620 }
621
622 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
623 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
624 cache.insert(key, proof);
625 });
626}
627
628fn invalidate_internal_invocation_proof(
629 request: &InternalInvocationProofRequest,
630) -> Result<(), Error> {
631 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
632 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
633 let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
634 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
635 cache.remove(&key);
636 });
637 Ok(())
638}
639
640fn internal_invocation_proof_is_reusable(
641 proof: &SignedInternalInvocationProofV1,
642 request: &InternalInvocationProofRequest,
643 now_secs: u64,
644 min_accepted_epoch: u64,
645) -> bool {
646 let payload = &proof.payload;
647 payload.subject == request.subject
648 && payload.role == request.role
649 && payload.subnet_id == request.subnet_id
650 && payload.audience == request.audience
651 && payload.audience_method == request.audience_method
652 && payload.epoch >= min_accepted_epoch
653 && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
654 < payload.expires_at
655}
656
657fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
658 proof
659 .payload
660 .expires_at
661 .saturating_sub(proof.payload.issued_at)
662 .saturating_div(5)
663 .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
664}
665
666fn internal_call_result_is_retryable(result: &CallResult) -> bool {
667 let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
668 return false;
669 };
670 internal_call_error_is_retryable(&err)
671}
672
673const fn internal_call_error_is_retryable(err: &Error) -> bool {
674 matches!(
675 err.code,
676 ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
677 )
678}
679
680#[derive(Clone, Copy, Debug, Eq, PartialEq)]
681enum WaitMode {
682 Bounded,
683 Unbounded,
684}
685
686fn build_internal_call_envelope(
687 target_canister: Principal,
688 target_method: &str,
689 proof: SignedInternalInvocationProofV1,
690 args: Vec<u8>,
691) -> CanicInternalCallEnvelopeV1 {
692 CanicInternalCallEnvelopeV1 {
693 version: 1,
694 header: CanicInternalCallHeaderV1 {
695 target_canister,
696 target_method: target_method.to_string(),
697 },
698 proof,
699 args,
700 }
701}
702
703pub struct IntentKey(BoundedString128);
718
719impl IntentKey {
720 pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
721 BoundedString128::try_new(value)
722 .map(Self)
723 .map_err(Error::invalid)
724 }
725
726 #[must_use]
727 pub fn as_str(&self) -> &str {
728 self.0.as_str()
729 }
730
731 #[must_use]
732 pub fn into_inner(self) -> BoundedString128 {
733 self.0
734 }
735}
736
737impl AsRef<str> for IntentKey {
738 fn as_ref(&self) -> &str {
739 self.0.as_str()
740 }
741}
742
743impl From<IntentKey> for BoundedString128 {
744 fn from(key: IntentKey) -> Self {
745 key.0
746 }
747}
748
749pub struct IntentReservation {
768 key: IntentKey,
769 quantity: u64,
770 ttl_secs: Option<u64>,
771 max_in_flight: Option<u64>,
772}
773
774impl IntentReservation {
775 #[must_use]
776 pub const fn new(key: IntentKey, quantity: u64) -> Self {
777 Self {
778 key,
779 quantity,
780 ttl_secs: None,
781 max_in_flight: None,
782 }
783 }
784
785 #[must_use]
786 pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
787 self.ttl_secs = Some(ttl_secs);
788 self
789 }
790
791 #[must_use]
792 pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
793 self.max_in_flight = Some(max_in_flight);
794 self
795 }
796
797 pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
798 WorkflowIntentSpec::new(
799 self.key.into(),
800 self.quantity,
801 self.ttl_secs,
802 self.max_in_flight,
803 )
804 }
805}
806
807pub struct CallBuilder<'a> {
812 inner: WorkflowCallBuilder<'a>,
813}
814
815impl CallBuilder<'_> {
816 pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
820 where
821 A: CandidType,
822 {
823 Ok(Self {
824 inner: self.inner.with_arg(arg).map_err(Error::from)?,
825 })
826 }
827
828 pub fn with_args<A>(self, args: A) -> Result<Self, Error>
830 where
831 A: ArgumentEncoder,
832 {
833 Ok(Self {
834 inner: self.inner.with_args(args).map_err(Error::from)?,
835 })
836 }
837
838 #[must_use]
840 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
841 CallBuilder {
842 inner: self.inner.with_raw_args(args),
843 }
844 }
845
846 #[must_use]
849 pub fn with_cycles(self, cycles: u128) -> Self {
850 Self {
851 inner: self.inner.with_cycles(cycles),
852 }
853 }
854
855 #[must_use]
858 pub fn with_intent(self, intent: IntentReservation) -> Self {
859 Self {
860 inner: self.inner.with_intent(intent.into_spec()),
861 }
862 }
863
864 pub async fn execute(self) -> Result<CallResult, Error> {
867 Ok(CallResult {
868 inner: self.inner.execute().await.map_err(Error::from)?,
869 })
870 }
871}
872
873pub struct CallResult {
885 inner: WorkflowCallResult,
886}
887
888impl CallResult {
889 pub fn candid<R>(&self) -> Result<R, Error>
890 where
891 R: CandidType + DeserializeOwned,
892 {
893 self.inner.candid().map_err(Error::from)
894 }
895
896 pub fn candid_tuple<R>(&self) -> Result<R, Error>
897 where
898 R: for<'de> ArgumentDecoder<'de>,
899 {
900 self.inner.candid_tuple().map_err(Error::from)
901 }
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use crate::config::schema::RoleAttestationConfig;
908 use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
909 use candid::decode_args;
910 use std::collections::BTreeMap;
911
912 fn p(id: u8) -> Principal {
913 Principal::from_slice(&[id; 29])
914 }
915
916 fn proof() -> SignedInternalInvocationProofV1 {
917 SignedInternalInvocationProofV1 {
918 payload: InternalInvocationProofPayloadV1 {
919 subject: p(1),
920 role: CanisterRole::new("project_hub"),
921 subnet_id: None,
922 audience: p(2),
923 audience_method: "system_add_project_to_user".to_string(),
924 issued_at: 10,
925 expires_at: 20,
926 epoch: 3,
927 },
928 signature: vec![1, 2, 3],
929 key_id: 1,
930 }
931 }
932
933 fn request() -> InternalInvocationProofRequest {
934 InternalInvocationProofRequest {
935 subject: p(1),
936 role: CanisterRole::new("project_hub"),
937 subnet_id: Some(p(9)),
938 audience: p(2),
939 audience_method: "system_add_project_to_user".to_string(),
940 ttl_secs: 120,
941 metadata: None,
942 }
943 }
944
945 fn cfg(min_epoch: u64) -> RoleAttestationConfig {
946 let mut min_accepted_epoch_by_role = BTreeMap::new();
947 min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
948 RoleAttestationConfig {
949 ecdsa_key_name: "key_1".to_string(),
950 max_ttl_secs: 900,
951 min_accepted_epoch_by_role,
952 }
953 }
954
955 fn clear_internal_invocation_proof_cache() {
956 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
957 }
958
959 #[test]
960 fn canic_call_envelope_binds_target_method_and_original_args() {
961 let args = encode_args((7_u64, "project")).expect("args encode");
962 let envelope =
963 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
964
965 assert_eq!(envelope.version, 1);
966 assert_eq!(envelope.header.target_canister, p(2));
967 assert_eq!(envelope.header.target_method, "system_add_project_to_user");
968 assert_eq!(
969 envelope.proof.payload.audience_method,
970 "system_add_project_to_user"
971 );
972
973 let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
974 assert_eq!(decoded, (7, "project".to_string()));
975 }
976
977 #[test]
978 fn canic_call_builder_records_role_and_raw_args() {
979 let raw = vec![9_u8, 8, 7];
980 let builder = CanicCall::unbounded_wait(p(3), "target")
981 .with_caller_role(CanisterRole::new("project_hub"))
982 .with_proof_ttl_secs(30)
983 .with_cycles(10)
984 .with_raw_args(raw.clone());
985
986 assert_eq!(builder.wait, WaitMode::Unbounded);
987 assert_eq!(builder.canister_id, p(3));
988 assert_eq!(builder.method, "target");
989 assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
990 assert_eq!(builder.ttl_secs, Some(30));
991 assert_eq!(builder.cycles, 10);
992 assert_eq!(builder.args.as_ref(), raw.as_slice());
993 }
994
995 #[test]
996 fn protected_internal_endpoint_descriptor_matches_roles() {
997 let endpoint = ProtectedInternalEndpoint::new(
998 "system_add_project_to_user",
999 [
1000 CanisterRole::new("project_hub"),
1001 CanisterRole::new("admin_hub"),
1002 ],
1003 );
1004
1005 assert_eq!(endpoint.method(), "system_add_project_to_user");
1006 assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
1007 assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
1008 assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
1009 assert!(endpoint.single_role().is_none());
1010 }
1011
1012 #[test]
1013 fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
1014 let endpoint = ProtectedInternalEndpoint::new(
1015 "system_add_project_to_user",
1016 [CanisterRole::new("project_hub")],
1017 );
1018
1019 assert_eq!(
1020 endpoint.single_role(),
1021 Some(&CanisterRole::new("project_hub"))
1022 );
1023 assert_eq!(
1024 endpoint.required_single_role().expect("single role"),
1025 CanisterRole::new("project_hub")
1026 );
1027 }
1028
1029 #[test]
1030 fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
1031 let endpoint = ProtectedInternalEndpoint::new(
1032 "system_add_project_to_user",
1033 [
1034 CanisterRole::new("project_hub"),
1035 CanisterRole::new("admin_hub"),
1036 ],
1037 );
1038
1039 let err = endpoint
1040 .required_single_role()
1041 .expect_err("multi-role endpoint should require explicit caller role");
1042 assert_eq!(err.code, ErrorCode::InvalidInput);
1043 }
1044
1045 #[test]
1046 fn internal_client_options_are_chainable() {
1047 let client = CanicInternalClient::new(p(3))
1048 .with_bounded_wait()
1049 .with_cycles(10)
1050 .with_proof_ttl_secs(30);
1051
1052 assert_eq!(client.canister_id, p(3));
1053 assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
1054 assert_eq!(client.options.cycles, 10);
1055 assert_eq!(client.options.proof_ttl_secs, Some(30));
1056 }
1057
1058 #[test]
1059 fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
1060 clear_internal_invocation_proof_cache();
1061 let request = request();
1062 let mut proof = proof();
1063 proof.payload.subnet_id = request.subnet_id;
1064 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
1065
1066 let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
1067 .expect("fresh matching proof should cache-hit");
1068
1069 assert_eq!(cached, proof);
1070 }
1071
1072 #[test]
1073 fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
1074 clear_internal_invocation_proof_cache();
1075 let request = request();
1076 let mut proof = proof();
1077 proof.payload.subnet_id = request.subnet_id;
1078 proof.payload.issued_at = 10;
1079 proof.payload.expires_at = 20;
1080 cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
1081
1082 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
1083 }
1084
1085 #[test]
1086 fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
1087 clear_internal_invocation_proof_cache();
1088 let request = request();
1089 let mut proof = proof();
1090 proof.payload.subnet_id = request.subnet_id;
1091 proof.payload.epoch = 3;
1092 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1093
1094 assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
1095 }
1096
1097 #[test]
1098 fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
1099 assert!(internal_call_error_is_retryable(&Error::new(
1100 ErrorCode::AuthKeyUnknown,
1101 "unknown key".to_string(),
1102 )));
1103 assert!(internal_call_error_is_retryable(&Error::new(
1104 ErrorCode::AuthMaterialStale,
1105 "stale epoch".to_string(),
1106 )));
1107 assert!(!internal_call_error_is_retryable(&Error::new(
1108 ErrorCode::AuthProofExpired,
1109 "expired".to_string(),
1110 )));
1111 assert!(!internal_call_error_is_retryable(&Error::unauthorized(
1112 "role mismatch"
1113 )));
1114 }
1115}