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}
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
257pub 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#[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
592pub 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
638pub 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
696pub struct CallBuilder<'a> {
701 inner: WorkflowCallBuilder<'a>,
702}
703
704impl CallBuilder<'_> {
705 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 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 #[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 #[must_use]
738 pub fn with_cycles(self, cycles: u128) -> Self {
739 Self {
740 inner: self.inner.with_cycles(cycles),
741 }
742 }
743
744 #[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 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
762pub 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}