Skip to main content

static_tr_plugin/domain/
client.rs

1//! Client implementation for the static tenant resolver plugin.
2//!
3//! Implements `TenantResolverPluginClient` using the domain service.
4
5use async_trait::async_trait;
6use modkit_security::SecurityContext;
7use tenant_resolver_sdk::{
8    GetAncestorsOptions, GetAncestorsResponse, GetDescendantsOptions, GetDescendantsResponse,
9    GetTenantsOptions, IsAncestorOptions, TenantId, TenantInfo, TenantResolverError,
10    TenantResolverPluginClient, matches_status,
11};
12
13use super::service::Service;
14
15#[async_trait]
16impl TenantResolverPluginClient for Service {
17    async fn get_tenant(
18        &self,
19        _ctx: &SecurityContext,
20        id: TenantId,
21    ) -> Result<TenantInfo, TenantResolverError> {
22        self.tenants
23            .get(&id)
24            .cloned()
25            .ok_or(TenantResolverError::TenantNotFound { tenant_id: id })
26    }
27
28    async fn get_tenants(
29        &self,
30        _ctx: &SecurityContext,
31        ids: &[TenantId],
32        options: &GetTenantsOptions,
33    ) -> Result<Vec<TenantInfo>, TenantResolverError> {
34        let mut result = Vec::new();
35        let mut seen = std::collections::HashSet::new();
36
37        for id in ids {
38            if !seen.insert(id) {
39                continue; // Skip duplicate IDs
40            }
41            if let Some(tenant) = self.tenants.get(id)
42                && matches_status(tenant, &options.status)
43            {
44                result.push(tenant.clone());
45            }
46            // Missing IDs are silently skipped
47        }
48
49        Ok(result)
50    }
51
52    async fn get_ancestors(
53        &self,
54        _ctx: &SecurityContext,
55        id: TenantId,
56        options: &GetAncestorsOptions,
57    ) -> Result<GetAncestorsResponse, TenantResolverError> {
58        // Get the tenant first
59        let tenant = self
60            .tenants
61            .get(&id)
62            .ok_or(TenantResolverError::TenantNotFound { tenant_id: id })?;
63
64        // Collect ancestors
65        let ancestors = self.collect_ancestors(id, options.barrier_mode);
66
67        Ok(GetAncestorsResponse {
68            tenant: tenant.into(),
69            ancestors,
70        })
71    }
72
73    async fn get_descendants(
74        &self,
75        _ctx: &SecurityContext,
76        id: TenantId,
77        options: &GetDescendantsOptions,
78    ) -> Result<GetDescendantsResponse, TenantResolverError> {
79        // Get the tenant first (filter does NOT apply to the starting tenant)
80        let tenant = self
81            .tenants
82            .get(&id)
83            .ok_or(TenantResolverError::TenantNotFound { tenant_id: id })?;
84
85        // Collect descendants with filter applied during traversal:
86        // - Results are in pre-order (parent before children)
87        // - Nodes that don't pass filter are excluded along with their subtrees
88        let descendants =
89            self.collect_descendants(id, &options.status, options.barrier_mode, options.max_depth);
90
91        Ok(GetDescendantsResponse {
92            tenant: tenant.into(),
93            descendants,
94        })
95    }
96
97    async fn is_ancestor(
98        &self,
99        _ctx: &SecurityContext,
100        ancestor_id: TenantId,
101        descendant_id: TenantId,
102        options: &IsAncestorOptions,
103    ) -> Result<bool, TenantResolverError> {
104        self.is_ancestor_of(ancestor_id, descendant_id, options.barrier_mode)
105    }
106}
107
108#[cfg(test)]
109#[cfg_attr(coverage_nightly, coverage(off))]
110mod tests {
111    use super::*;
112    use crate::config::{StaticTrPluginConfig, TenantConfig};
113    use tenant_resolver_sdk::{BarrierMode, TenantStatus};
114    use uuid::Uuid;
115
116    // Helper to create a test tenant config
117    fn tenant(id: &str, name: &str, status: TenantStatus) -> TenantConfig {
118        TenantConfig {
119            id: Uuid::parse_str(id).unwrap(),
120            name: name.to_owned(),
121            status,
122            tenant_type: None,
123            parent_id: None,
124            self_managed: false,
125        }
126    }
127
128    fn tenant_with_parent(id: &str, name: &str, parent: &str) -> TenantConfig {
129        TenantConfig {
130            id: Uuid::parse_str(id).unwrap(),
131            name: name.to_owned(),
132            status: TenantStatus::Active,
133            tenant_type: None,
134            parent_id: Some(Uuid::parse_str(parent).unwrap()),
135            self_managed: false,
136        }
137    }
138
139    fn tenant_barrier(id: &str, name: &str, parent: &str) -> TenantConfig {
140        TenantConfig {
141            id: Uuid::parse_str(id).unwrap(),
142            name: name.to_owned(),
143            status: TenantStatus::Active,
144            tenant_type: None,
145            parent_id: Some(Uuid::parse_str(parent).unwrap()),
146            self_managed: true,
147        }
148    }
149
150    // Helper to create a security context for a tenant
151    fn ctx_for_tenant(tenant_id: &str) -> SecurityContext {
152        SecurityContext::builder()
153            .subject_id(Uuid::new_v4())
154            .subject_tenant_id(Uuid::parse_str(tenant_id).unwrap())
155            .build()
156            .unwrap()
157    }
158
159    // Test UUIDs
160    const TENANT_A: &str = "11111111-1111-1111-1111-111111111111";
161    const TENANT_B: &str = "22222222-2222-2222-2222-222222222222";
162    const TENANT_C: &str = "33333333-3333-3333-3333-333333333333";
163    const TENANT_D: &str = "44444444-4444-4444-4444-444444444444";
164    const NONEXISTENT: &str = "99999999-9999-9999-9999-999999999999";
165
166    // ==================== get_tenant tests ====================
167
168    #[tokio::test]
169    async fn get_tenant_existing() {
170        let cfg = StaticTrPluginConfig {
171            tenants: vec![tenant(TENANT_A, "Tenant A", TenantStatus::Active)],
172            ..Default::default()
173        };
174        let service = Service::from_config(&cfg);
175        let ctx = ctx_for_tenant(TENANT_A);
176
177        let result = service
178            .get_tenant(&ctx, TenantId(Uuid::parse_str(TENANT_A).unwrap()))
179            .await;
180
181        assert!(result.is_ok());
182        let info = result.unwrap();
183        assert_eq!(info.name, "Tenant A");
184        assert_eq!(info.status, TenantStatus::Active);
185    }
186
187    #[tokio::test]
188    async fn get_tenant_nonexistent() {
189        let cfg = StaticTrPluginConfig {
190            tenants: vec![tenant(TENANT_A, "Tenant A", TenantStatus::Active)],
191            ..Default::default()
192        };
193        let service = Service::from_config(&cfg);
194        let ctx = ctx_for_tenant(TENANT_A);
195        let nonexistent_id = TenantId(Uuid::parse_str(NONEXISTENT).unwrap());
196
197        let result = service.get_tenant(&ctx, nonexistent_id).await;
198
199        assert!(result.is_err());
200        match result.unwrap_err() {
201            TenantResolverError::TenantNotFound { tenant_id } => {
202                assert_eq!(tenant_id, nonexistent_id);
203            }
204            other => panic!("Expected TenantNotFound, got: {other:?}"),
205        }
206    }
207
208    // ==================== get_tenants tests ====================
209
210    #[tokio::test]
211    async fn get_tenants_all_found() {
212        let cfg = StaticTrPluginConfig {
213            tenants: vec![
214                tenant(TENANT_A, "A", TenantStatus::Active),
215                tenant(TENANT_B, "B", TenantStatus::Active),
216                tenant(TENANT_C, "C", TenantStatus::Suspended),
217            ],
218            ..Default::default()
219        };
220        let service = Service::from_config(&cfg);
221        let ctx = ctx_for_tenant(TENANT_A);
222
223        let ids = vec![
224            TenantId(Uuid::parse_str(TENANT_A).unwrap()),
225            TenantId(Uuid::parse_str(TENANT_B).unwrap()),
226        ];
227
228        let result = service
229            .get_tenants(&ctx, &ids, &GetTenantsOptions::default())
230            .await;
231        assert!(result.is_ok());
232        let tenants = result.unwrap();
233        assert_eq!(tenants.len(), 2);
234    }
235
236    #[tokio::test]
237    async fn get_tenants_some_missing() {
238        let cfg = StaticTrPluginConfig {
239            tenants: vec![tenant(TENANT_A, "A", TenantStatus::Active)],
240            ..Default::default()
241        };
242        let service = Service::from_config(&cfg);
243        let ctx = ctx_for_tenant(TENANT_A);
244
245        let ids = vec![
246            TenantId(Uuid::parse_str(TENANT_A).unwrap()),
247            TenantId(Uuid::parse_str(NONEXISTENT).unwrap()), // This one doesn't exist
248        ];
249
250        let result = service
251            .get_tenants(&ctx, &ids, &GetTenantsOptions::default())
252            .await;
253        assert!(result.is_ok());
254        let tenants = result.unwrap();
255        // Only found tenant is returned, missing is silently skipped
256        assert_eq!(tenants.len(), 1);
257        assert_eq!(tenants[0].id, TenantId(Uuid::parse_str(TENANT_A).unwrap()));
258    }
259
260    #[tokio::test]
261    async fn get_tenants_with_filter() {
262        let cfg = StaticTrPluginConfig {
263            tenants: vec![
264                tenant(TENANT_A, "A", TenantStatus::Active),
265                tenant(TENANT_B, "B", TenantStatus::Suspended),
266            ],
267            ..Default::default()
268        };
269        let service = Service::from_config(&cfg);
270        let ctx = ctx_for_tenant(TENANT_A);
271
272        let ids = vec![
273            TenantId(Uuid::parse_str(TENANT_A).unwrap()),
274            TenantId(Uuid::parse_str(TENANT_B).unwrap()),
275        ];
276
277        let opts = GetTenantsOptions {
278            status: vec![TenantStatus::Active],
279        };
280        let result = service.get_tenants(&ctx, &ids, &opts).await;
281        assert!(result.is_ok());
282        let tenants = result.unwrap();
283        // Only active tenant is returned
284        assert_eq!(tenants.len(), 1);
285        assert_eq!(tenants[0].id, TenantId(Uuid::parse_str(TENANT_A).unwrap()));
286    }
287
288    // ==================== get_ancestors tests ====================
289
290    #[tokio::test]
291    async fn get_ancestors_root_tenant() {
292        let cfg = StaticTrPluginConfig {
293            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
294            ..Default::default()
295        };
296        let service = Service::from_config(&cfg);
297        let ctx = ctx_for_tenant(TENANT_A);
298
299        let result = service
300            .get_ancestors(
301                &ctx,
302                TenantId(Uuid::parse_str(TENANT_A).unwrap()),
303                &GetAncestorsOptions::default(),
304            )
305            .await;
306
307        assert!(result.is_ok());
308        let response = result.unwrap();
309        assert_eq!(
310            response.tenant.id,
311            TenantId(Uuid::parse_str(TENANT_A).unwrap())
312        );
313        assert!(response.ancestors.is_empty());
314    }
315
316    #[tokio::test]
317    async fn get_ancestors_with_hierarchy() {
318        // A -> B -> C
319        let cfg = StaticTrPluginConfig {
320            tenants: vec![
321                tenant(TENANT_A, "Root", TenantStatus::Active),
322                tenant_with_parent(TENANT_B, "Child", TENANT_A),
323                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
324            ],
325            ..Default::default()
326        };
327        let service = Service::from_config(&cfg);
328        let ctx = ctx_for_tenant(TENANT_C);
329
330        let result = service
331            .get_ancestors(
332                &ctx,
333                TenantId(Uuid::parse_str(TENANT_C).unwrap()),
334                &GetAncestorsOptions::default(),
335            )
336            .await;
337
338        assert!(result.is_ok());
339        let response = result.unwrap();
340        assert_eq!(
341            response.tenant.id,
342            TenantId(Uuid::parse_str(TENANT_C).unwrap())
343        );
344        assert_eq!(response.ancestors.len(), 2);
345        assert_eq!(
346            response.ancestors[0].id,
347            TenantId(Uuid::parse_str(TENANT_B).unwrap())
348        );
349        assert_eq!(
350            response.ancestors[1].id,
351            TenantId(Uuid::parse_str(TENANT_A).unwrap())
352        );
353    }
354
355    #[tokio::test]
356    async fn get_ancestors_with_barrier() {
357        // A -> B (barrier) -> C
358        let cfg = StaticTrPluginConfig {
359            tenants: vec![
360                tenant(TENANT_A, "Root", TenantStatus::Active),
361                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
362                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
363            ],
364            ..Default::default()
365        };
366        let service = Service::from_config(&cfg);
367        let ctx = ctx_for_tenant(TENANT_C);
368
369        // Default (BarrierMode::Respect) - stops at barrier
370        let result = service
371            .get_ancestors(
372                &ctx,
373                TenantId(Uuid::parse_str(TENANT_C).unwrap()),
374                &GetAncestorsOptions::default(),
375            )
376            .await;
377
378        assert!(result.is_ok());
379        let response = result.unwrap();
380        assert_eq!(response.ancestors.len(), 1);
381        assert_eq!(
382            response.ancestors[0].id,
383            TenantId(Uuid::parse_str(TENANT_B).unwrap())
384        );
385
386        // BarrierMode::Ignore - traverses through
387        let req = GetAncestorsOptions {
388            barrier_mode: BarrierMode::Ignore,
389        };
390        let result = service
391            .get_ancestors(&ctx, TenantId(Uuid::parse_str(TENANT_C).unwrap()), &req)
392            .await;
393
394        assert!(result.is_ok());
395        let response = result.unwrap();
396        assert_eq!(response.ancestors.len(), 2);
397    }
398
399    #[tokio::test]
400    async fn get_ancestors_nonexistent() {
401        let cfg = StaticTrPluginConfig::default();
402        let service = Service::from_config(&cfg);
403        let ctx = ctx_for_tenant(TENANT_A);
404
405        let result = service
406            .get_ancestors(
407                &ctx,
408                TenantId(Uuid::parse_str(NONEXISTENT).unwrap()),
409                &GetAncestorsOptions::default(),
410            )
411            .await;
412
413        assert!(result.is_err());
414        match result.unwrap_err() {
415            TenantResolverError::TenantNotFound { .. } => {}
416            other => panic!("Expected TenantNotFound, got: {other:?}"),
417        }
418    }
419
420    #[tokio::test]
421    async fn get_ancestors_starting_tenant_is_barrier() {
422        // A -> B (barrier)
423        // get_ancestors(B) should return empty because B is a barrier
424        let cfg = StaticTrPluginConfig {
425            tenants: vec![
426                tenant(TENANT_A, "Root", TenantStatus::Active),
427                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
428            ],
429            ..Default::default()
430        };
431        let service = Service::from_config(&cfg);
432        let ctx = ctx_for_tenant(TENANT_B);
433
434        // Default (BarrierMode::Respect) - B cannot see its parent chain
435        let result = service
436            .get_ancestors(
437                &ctx,
438                TenantId(Uuid::parse_str(TENANT_B).unwrap()),
439                &GetAncestorsOptions::default(),
440            )
441            .await;
442
443        assert!(result.is_ok());
444        let response = result.unwrap();
445        assert_eq!(
446            response.tenant.id,
447            TenantId(Uuid::parse_str(TENANT_B).unwrap())
448        );
449        assert!(response.ancestors.is_empty());
450
451        // BarrierMode::Ignore - B can see A
452        let req = GetAncestorsOptions {
453            barrier_mode: BarrierMode::Ignore,
454        };
455        let result = service
456            .get_ancestors(&ctx, TenantId(Uuid::parse_str(TENANT_B).unwrap()), &req)
457            .await;
458
459        assert!(result.is_ok());
460        let response = result.unwrap();
461        assert_eq!(response.ancestors.len(), 1);
462        assert_eq!(
463            response.ancestors[0].id,
464            TenantId(Uuid::parse_str(TENANT_A).unwrap())
465        );
466    }
467
468    // ==================== get_descendants tests ====================
469
470    #[tokio::test]
471    async fn get_descendants_no_children() {
472        let cfg = StaticTrPluginConfig {
473            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
474            ..Default::default()
475        };
476        let service = Service::from_config(&cfg);
477        let ctx = ctx_for_tenant(TENANT_A);
478
479        let result = service
480            .get_descendants(
481                &ctx,
482                TenantId(Uuid::parse_str(TENANT_A).unwrap()),
483                &GetDescendantsOptions::default(),
484            )
485            .await;
486
487        assert!(result.is_ok());
488        let response = result.unwrap();
489        assert_eq!(
490            response.tenant.id,
491            TenantId(Uuid::parse_str(TENANT_A).unwrap())
492        );
493        assert!(response.descendants.is_empty());
494    }
495
496    #[tokio::test]
497    async fn get_descendants_with_hierarchy() {
498        // A -> B -> C
499        let cfg = StaticTrPluginConfig {
500            tenants: vec![
501                tenant(TENANT_A, "Root", TenantStatus::Active),
502                tenant_with_parent(TENANT_B, "Child", TENANT_A),
503                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
504            ],
505            ..Default::default()
506        };
507        let service = Service::from_config(&cfg);
508        let ctx = ctx_for_tenant(TENANT_A);
509
510        // Unlimited depth
511        let result = service
512            .get_descendants(
513                &ctx,
514                TenantId(Uuid::parse_str(TENANT_A).unwrap()),
515                &GetDescendantsOptions::default(),
516            )
517            .await;
518
519        assert!(result.is_ok());
520        let response = result.unwrap();
521        assert_eq!(
522            response.tenant.id,
523            TenantId(Uuid::parse_str(TENANT_A).unwrap())
524        );
525        assert_eq!(response.descendants.len(), 2);
526
527        // Depth 1 only
528        let req = GetDescendantsOptions {
529            max_depth: Some(1),
530            ..Default::default()
531        };
532        let result = service
533            .get_descendants(&ctx, TenantId(Uuid::parse_str(TENANT_A).unwrap()), &req)
534            .await;
535
536        assert!(result.is_ok());
537        let response = result.unwrap();
538        assert_eq!(response.descendants.len(), 1);
539        assert_eq!(
540            response.descendants[0].id,
541            TenantId(Uuid::parse_str(TENANT_B).unwrap())
542        );
543    }
544
545    #[tokio::test]
546    async fn get_descendants_with_barrier() {
547        // A -> B (barrier) -> C
548        //   -> D
549        let cfg = StaticTrPluginConfig {
550            tenants: vec![
551                tenant(TENANT_A, "Root", TenantStatus::Active),
552                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
553                tenant_with_parent(TENANT_C, "Under Barrier", TENANT_B),
554                tenant_with_parent(TENANT_D, "Normal Child", TENANT_A),
555            ],
556            ..Default::default()
557        };
558        let service = Service::from_config(&cfg);
559        let ctx = ctx_for_tenant(TENANT_A);
560
561        // Default (BarrierMode::Respect) - only D is visible
562        let result = service
563            .get_descendants(
564                &ctx,
565                TenantId(Uuid::parse_str(TENANT_A).unwrap()),
566                &GetDescendantsOptions::default(),
567            )
568            .await;
569
570        assert!(result.is_ok());
571        let response = result.unwrap();
572        assert_eq!(response.descendants.len(), 1);
573        assert_eq!(
574            response.descendants[0].id,
575            TenantId(Uuid::parse_str(TENANT_D).unwrap())
576        );
577
578        // BarrierMode::Ignore - all descendants visible
579        let req = GetDescendantsOptions {
580            barrier_mode: BarrierMode::Ignore,
581            ..Default::default()
582        };
583        let result = service
584            .get_descendants(&ctx, TenantId(Uuid::parse_str(TENANT_A).unwrap()), &req)
585            .await;
586
587        assert!(result.is_ok());
588        let response = result.unwrap();
589        assert_eq!(response.descendants.len(), 3);
590    }
591
592    #[tokio::test]
593    async fn get_descendants_nonexistent() {
594        let cfg = StaticTrPluginConfig::default();
595        let service = Service::from_config(&cfg);
596        let ctx = ctx_for_tenant(TENANT_A);
597
598        let result = service
599            .get_descendants(
600                &ctx,
601                TenantId(Uuid::parse_str(NONEXISTENT).unwrap()),
602                &GetDescendantsOptions::default(),
603            )
604            .await;
605
606        assert!(result.is_err());
607        match result.unwrap_err() {
608            TenantResolverError::TenantNotFound { .. } => {}
609            other => panic!("Expected TenantNotFound, got: {other:?}"),
610        }
611    }
612
613    #[tokio::test]
614    async fn get_descendants_filter_stops_traversal() {
615        // A (active) -> B (suspended) -> C (active)
616        //           -> D (active)
617        // Filter for active-only should return D only, NOT C
618        // (because B doesn't pass filter, so its subtree is excluded)
619        let cfg = StaticTrPluginConfig {
620            tenants: vec![
621                tenant(TENANT_A, "Root", TenantStatus::Active),
622                {
623                    let mut t = tenant_with_parent(TENANT_B, "Suspended", TENANT_A);
624                    t.status = TenantStatus::Suspended;
625                    t
626                },
627                tenant_with_parent(TENANT_C, "Child of Suspended", TENANT_B),
628                tenant_with_parent(TENANT_D, "Active Child", TENANT_A),
629            ],
630            ..Default::default()
631        };
632        let service = Service::from_config(&cfg);
633        let ctx = ctx_for_tenant(TENANT_A);
634
635        // Without filter: all 3 descendants (pre-order: B, C, D)
636        let result = service
637            .get_descendants(
638                &ctx,
639                TenantId(Uuid::parse_str(TENANT_A).unwrap()),
640                &GetDescendantsOptions::default(),
641            )
642            .await
643            .unwrap();
644        assert_eq!(result.descendants.len(), 3);
645
646        // With active-only filter: only D (B filtered out, so C is unreachable)
647        let req = GetDescendantsOptions {
648            status: vec![TenantStatus::Active],
649            ..Default::default()
650        };
651        let result = service
652            .get_descendants(&ctx, TenantId(Uuid::parse_str(TENANT_A).unwrap()), &req)
653            .await
654            .unwrap();
655
656        assert_eq!(result.descendants.len(), 1);
657        assert_eq!(
658            result.descendants[0].id,
659            TenantId(Uuid::parse_str(TENANT_D).unwrap())
660        );
661    }
662
663    #[tokio::test]
664    async fn get_descendants_pre_order() {
665        // Verify pre-order traversal: parent before children
666        // A -> B -> C
667        // Pre-order from A: B first, then C (B must come before its child C)
668        // Note: Sibling order is not guaranteed (HashMap), so we test a linear chain
669        let cfg = StaticTrPluginConfig {
670            tenants: vec![
671                tenant(TENANT_A, "Root", TenantStatus::Active),
672                tenant_with_parent(TENANT_B, "Child B", TENANT_A),
673                tenant_with_parent(TENANT_C, "Grandchild C", TENANT_B),
674            ],
675            ..Default::default()
676        };
677        let service = Service::from_config(&cfg);
678        let ctx = ctx_for_tenant(TENANT_A);
679
680        let result = service
681            .get_descendants(
682                &ctx,
683                TenantId(Uuid::parse_str(TENANT_A).unwrap()),
684                &GetDescendantsOptions::default(),
685            )
686            .await
687            .unwrap();
688
689        assert_eq!(result.descendants.len(), 2);
690        // Pre-order guarantee: B comes before C (parent before child)
691        assert_eq!(
692            result.descendants[0].id,
693            TenantId(Uuid::parse_str(TENANT_B).unwrap())
694        );
695        assert_eq!(
696            result.descendants[1].id,
697            TenantId(Uuid::parse_str(TENANT_C).unwrap())
698        );
699    }
700
701    // ==================== is_ancestor tests ====================
702
703    #[tokio::test]
704    async fn is_ancestor_self_returns_false() {
705        let cfg = StaticTrPluginConfig {
706            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
707            ..Default::default()
708        };
709        let service = Service::from_config(&cfg);
710        let ctx = ctx_for_tenant(TENANT_A);
711        let a_id = TenantId(Uuid::parse_str(TENANT_A).unwrap());
712
713        let result = service
714            .is_ancestor(&ctx, a_id, a_id, &IsAncestorOptions::default())
715            .await;
716        assert!(result.is_ok());
717        assert!(!result.unwrap());
718    }
719
720    #[tokio::test]
721    async fn is_ancestor_direct_parent() {
722        let cfg = StaticTrPluginConfig {
723            tenants: vec![
724                tenant(TENANT_A, "Root", TenantStatus::Active),
725                tenant_with_parent(TENANT_B, "Child", TENANT_A),
726            ],
727            ..Default::default()
728        };
729        let service = Service::from_config(&cfg);
730        let ctx = ctx_for_tenant(TENANT_A);
731
732        let a_id = TenantId(Uuid::parse_str(TENANT_A).unwrap());
733        let b_id = TenantId(Uuid::parse_str(TENANT_B).unwrap());
734
735        // A is ancestor of B
736        let result = service
737            .is_ancestor(&ctx, a_id, b_id, &IsAncestorOptions::default())
738            .await;
739        assert!(result.is_ok());
740        assert!(result.unwrap());
741
742        // B is NOT ancestor of A
743        let result = service
744            .is_ancestor(&ctx, b_id, a_id, &IsAncestorOptions::default())
745            .await;
746        assert!(result.is_ok());
747        assert!(!result.unwrap());
748    }
749
750    #[tokio::test]
751    async fn is_ancestor_with_barrier() {
752        // A -> B (barrier) -> C
753        let cfg = StaticTrPluginConfig {
754            tenants: vec![
755                tenant(TENANT_A, "Root", TenantStatus::Active),
756                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
757                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
758            ],
759            ..Default::default()
760        };
761        let service = Service::from_config(&cfg);
762        let ctx = ctx_for_tenant(TENANT_A);
763
764        let a_id = TenantId(Uuid::parse_str(TENANT_A).unwrap());
765        let b_id = TenantId(Uuid::parse_str(TENANT_B).unwrap());
766        let c_id = TenantId(Uuid::parse_str(TENANT_C).unwrap());
767
768        // B is direct parent of C - allowed
769        let result = service
770            .is_ancestor(&ctx, b_id, c_id, &IsAncestorOptions::default())
771            .await;
772        assert!(result.unwrap());
773
774        // A blocked by barrier B
775        let result = service
776            .is_ancestor(&ctx, a_id, c_id, &IsAncestorOptions::default())
777            .await;
778        assert!(!result.unwrap());
779
780        // With BarrierMode::Ignore - A is ancestor of C
781        let req = IsAncestorOptions {
782            barrier_mode: BarrierMode::Ignore,
783        };
784        let result = service.is_ancestor(&ctx, a_id, c_id, &req).await;
785        assert!(result.unwrap());
786    }
787
788    #[tokio::test]
789    async fn is_ancestor_direct_barrier_child() {
790        // A -> B (barrier)
791        // is_ancestor(A, B) should return false because B is a barrier
792        let cfg = StaticTrPluginConfig {
793            tenants: vec![
794                tenant(TENANT_A, "Root", TenantStatus::Active),
795                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
796            ],
797            ..Default::default()
798        };
799        let service = Service::from_config(&cfg);
800        let ctx = ctx_for_tenant(TENANT_A);
801
802        let a_id = TenantId(Uuid::parse_str(TENANT_A).unwrap());
803        let b_id = TenantId(Uuid::parse_str(TENANT_B).unwrap());
804
805        // A is NOT ancestor of B when B is a barrier (default BarrierMode::Respect)
806        let result = service
807            .is_ancestor(&ctx, a_id, b_id, &IsAncestorOptions::default())
808            .await;
809        assert!(!result.unwrap());
810
811        // With BarrierMode::Ignore, A IS ancestor of B
812        let req = IsAncestorOptions {
813            barrier_mode: BarrierMode::Ignore,
814        };
815        let result = service.is_ancestor(&ctx, a_id, b_id, &req).await;
816        assert!(result.unwrap());
817    }
818
819    #[tokio::test]
820    async fn is_ancestor_nonexistent() {
821        let cfg = StaticTrPluginConfig {
822            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
823            ..Default::default()
824        };
825        let service = Service::from_config(&cfg);
826        let ctx = ctx_for_tenant(TENANT_A);
827
828        let a_id = TenantId(Uuid::parse_str(TENANT_A).unwrap());
829        let nonexistent = TenantId(Uuid::parse_str(NONEXISTENT).unwrap());
830
831        // Nonexistent ancestor
832        let result = service
833            .is_ancestor(&ctx, nonexistent, a_id, &IsAncestorOptions::default())
834            .await;
835        assert!(result.is_err());
836
837        // Nonexistent descendant
838        let result = service
839            .is_ancestor(&ctx, a_id, nonexistent, &IsAncestorOptions::default())
840            .await;
841        assert!(result.is_err());
842    }
843}