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, 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 = 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            Uuid::parse_str(TENANT_A).unwrap(),
225            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            Uuid::parse_str(TENANT_A).unwrap(),
247            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, 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            Uuid::parse_str(TENANT_A).unwrap(),
274            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, 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                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!(response.tenant.id, Uuid::parse_str(TENANT_A).unwrap());
310        assert!(response.ancestors.is_empty());
311    }
312
313    #[tokio::test]
314    async fn get_ancestors_with_hierarchy() {
315        // A -> B -> C
316        let cfg = StaticTrPluginConfig {
317            tenants: vec![
318                tenant(TENANT_A, "Root", TenantStatus::Active),
319                tenant_with_parent(TENANT_B, "Child", TENANT_A),
320                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
321            ],
322            ..Default::default()
323        };
324        let service = Service::from_config(&cfg);
325        let ctx = ctx_for_tenant(TENANT_C);
326
327        let result = service
328            .get_ancestors(
329                &ctx,
330                Uuid::parse_str(TENANT_C).unwrap(),
331                &GetAncestorsOptions::default(),
332            )
333            .await;
334
335        assert!(result.is_ok());
336        let response = result.unwrap();
337        assert_eq!(response.tenant.id, Uuid::parse_str(TENANT_C).unwrap());
338        assert_eq!(response.ancestors.len(), 2);
339        assert_eq!(response.ancestors[0].id, Uuid::parse_str(TENANT_B).unwrap());
340        assert_eq!(response.ancestors[1].id, Uuid::parse_str(TENANT_A).unwrap());
341    }
342
343    #[tokio::test]
344    async fn get_ancestors_with_barrier() {
345        // A -> B (barrier) -> C
346        let cfg = StaticTrPluginConfig {
347            tenants: vec![
348                tenant(TENANT_A, "Root", TenantStatus::Active),
349                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
350                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
351            ],
352            ..Default::default()
353        };
354        let service = Service::from_config(&cfg);
355        let ctx = ctx_for_tenant(TENANT_C);
356
357        // Default (BarrierMode::Respect) - stops at barrier
358        let result = service
359            .get_ancestors(
360                &ctx,
361                Uuid::parse_str(TENANT_C).unwrap(),
362                &GetAncestorsOptions::default(),
363            )
364            .await;
365
366        assert!(result.is_ok());
367        let response = result.unwrap();
368        assert_eq!(response.ancestors.len(), 1);
369        assert_eq!(response.ancestors[0].id, Uuid::parse_str(TENANT_B).unwrap());
370
371        // BarrierMode::Ignore - traverses through
372        let req = GetAncestorsOptions {
373            barrier_mode: BarrierMode::Ignore,
374        };
375        let result = service
376            .get_ancestors(&ctx, Uuid::parse_str(TENANT_C).unwrap(), &req)
377            .await;
378
379        assert!(result.is_ok());
380        let response = result.unwrap();
381        assert_eq!(response.ancestors.len(), 2);
382    }
383
384    #[tokio::test]
385    async fn get_ancestors_nonexistent() {
386        let cfg = StaticTrPluginConfig::default();
387        let service = Service::from_config(&cfg);
388        let ctx = ctx_for_tenant(TENANT_A);
389
390        let result = service
391            .get_ancestors(
392                &ctx,
393                Uuid::parse_str(NONEXISTENT).unwrap(),
394                &GetAncestorsOptions::default(),
395            )
396            .await;
397
398        assert!(result.is_err());
399        match result.unwrap_err() {
400            TenantResolverError::TenantNotFound { .. } => {}
401            other => panic!("Expected TenantNotFound, got: {other:?}"),
402        }
403    }
404
405    #[tokio::test]
406    async fn get_ancestors_starting_tenant_is_barrier() {
407        // A -> B (barrier)
408        // get_ancestors(B) should return empty because B is a barrier
409        let cfg = StaticTrPluginConfig {
410            tenants: vec![
411                tenant(TENANT_A, "Root", TenantStatus::Active),
412                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
413            ],
414            ..Default::default()
415        };
416        let service = Service::from_config(&cfg);
417        let ctx = ctx_for_tenant(TENANT_B);
418
419        // Default (BarrierMode::Respect) - B cannot see its parent chain
420        let result = service
421            .get_ancestors(
422                &ctx,
423                Uuid::parse_str(TENANT_B).unwrap(),
424                &GetAncestorsOptions::default(),
425            )
426            .await;
427
428        assert!(result.is_ok());
429        let response = result.unwrap();
430        assert_eq!(response.tenant.id, Uuid::parse_str(TENANT_B).unwrap());
431        assert!(response.ancestors.is_empty());
432
433        // BarrierMode::Ignore - B can see A
434        let req = GetAncestorsOptions {
435            barrier_mode: BarrierMode::Ignore,
436        };
437        let result = service
438            .get_ancestors(&ctx, Uuid::parse_str(TENANT_B).unwrap(), &req)
439            .await;
440
441        assert!(result.is_ok());
442        let response = result.unwrap();
443        assert_eq!(response.ancestors.len(), 1);
444        assert_eq!(response.ancestors[0].id, Uuid::parse_str(TENANT_A).unwrap());
445    }
446
447    // ==================== get_descendants tests ====================
448
449    #[tokio::test]
450    async fn get_descendants_no_children() {
451        let cfg = StaticTrPluginConfig {
452            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
453            ..Default::default()
454        };
455        let service = Service::from_config(&cfg);
456        let ctx = ctx_for_tenant(TENANT_A);
457
458        let result = service
459            .get_descendants(
460                &ctx,
461                Uuid::parse_str(TENANT_A).unwrap(),
462                &GetDescendantsOptions::default(),
463            )
464            .await;
465
466        assert!(result.is_ok());
467        let response = result.unwrap();
468        assert_eq!(response.tenant.id, Uuid::parse_str(TENANT_A).unwrap());
469        assert!(response.descendants.is_empty());
470    }
471
472    #[tokio::test]
473    async fn get_descendants_with_hierarchy() {
474        // A -> B -> C
475        let cfg = StaticTrPluginConfig {
476            tenants: vec![
477                tenant(TENANT_A, "Root", TenantStatus::Active),
478                tenant_with_parent(TENANT_B, "Child", TENANT_A),
479                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
480            ],
481            ..Default::default()
482        };
483        let service = Service::from_config(&cfg);
484        let ctx = ctx_for_tenant(TENANT_A);
485
486        // Unlimited depth
487        let result = service
488            .get_descendants(
489                &ctx,
490                Uuid::parse_str(TENANT_A).unwrap(),
491                &GetDescendantsOptions::default(),
492            )
493            .await;
494
495        assert!(result.is_ok());
496        let response = result.unwrap();
497        assert_eq!(response.tenant.id, Uuid::parse_str(TENANT_A).unwrap());
498        assert_eq!(response.descendants.len(), 2);
499
500        // Depth 1 only
501        let req = GetDescendantsOptions {
502            max_depth: Some(1),
503            ..Default::default()
504        };
505        let result = service
506            .get_descendants(&ctx, Uuid::parse_str(TENANT_A).unwrap(), &req)
507            .await;
508
509        assert!(result.is_ok());
510        let response = result.unwrap();
511        assert_eq!(response.descendants.len(), 1);
512        assert_eq!(
513            response.descendants[0].id,
514            Uuid::parse_str(TENANT_B).unwrap()
515        );
516    }
517
518    #[tokio::test]
519    async fn get_descendants_with_barrier() {
520        // A -> B (barrier) -> C
521        //   -> D
522        let cfg = StaticTrPluginConfig {
523            tenants: vec![
524                tenant(TENANT_A, "Root", TenantStatus::Active),
525                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
526                tenant_with_parent(TENANT_C, "Under Barrier", TENANT_B),
527                tenant_with_parent(TENANT_D, "Normal Child", TENANT_A),
528            ],
529            ..Default::default()
530        };
531        let service = Service::from_config(&cfg);
532        let ctx = ctx_for_tenant(TENANT_A);
533
534        // Default (BarrierMode::Respect) - only D is visible
535        let result = service
536            .get_descendants(
537                &ctx,
538                Uuid::parse_str(TENANT_A).unwrap(),
539                &GetDescendantsOptions::default(),
540            )
541            .await;
542
543        assert!(result.is_ok());
544        let response = result.unwrap();
545        assert_eq!(response.descendants.len(), 1);
546        assert_eq!(
547            response.descendants[0].id,
548            Uuid::parse_str(TENANT_D).unwrap()
549        );
550
551        // BarrierMode::Ignore - all descendants visible
552        let req = GetDescendantsOptions {
553            barrier_mode: BarrierMode::Ignore,
554            ..Default::default()
555        };
556        let result = service
557            .get_descendants(&ctx, Uuid::parse_str(TENANT_A).unwrap(), &req)
558            .await;
559
560        assert!(result.is_ok());
561        let response = result.unwrap();
562        assert_eq!(response.descendants.len(), 3);
563    }
564
565    #[tokio::test]
566    async fn get_descendants_nonexistent() {
567        let cfg = StaticTrPluginConfig::default();
568        let service = Service::from_config(&cfg);
569        let ctx = ctx_for_tenant(TENANT_A);
570
571        let result = service
572            .get_descendants(
573                &ctx,
574                Uuid::parse_str(NONEXISTENT).unwrap(),
575                &GetDescendantsOptions::default(),
576            )
577            .await;
578
579        assert!(result.is_err());
580        match result.unwrap_err() {
581            TenantResolverError::TenantNotFound { .. } => {}
582            other => panic!("Expected TenantNotFound, got: {other:?}"),
583        }
584    }
585
586    #[tokio::test]
587    async fn get_descendants_filter_stops_traversal() {
588        // A (active) -> B (suspended) -> C (active)
589        //           -> D (active)
590        // Filter for active-only should return D only, NOT C
591        // (because B doesn't pass filter, so its subtree is excluded)
592        let cfg = StaticTrPluginConfig {
593            tenants: vec![
594                tenant(TENANT_A, "Root", TenantStatus::Active),
595                {
596                    let mut t = tenant_with_parent(TENANT_B, "Suspended", TENANT_A);
597                    t.status = TenantStatus::Suspended;
598                    t
599                },
600                tenant_with_parent(TENANT_C, "Child of Suspended", TENANT_B),
601                tenant_with_parent(TENANT_D, "Active Child", TENANT_A),
602            ],
603            ..Default::default()
604        };
605        let service = Service::from_config(&cfg);
606        let ctx = ctx_for_tenant(TENANT_A);
607
608        // Without filter: all 3 descendants (pre-order: B, C, D)
609        let result = service
610            .get_descendants(
611                &ctx,
612                Uuid::parse_str(TENANT_A).unwrap(),
613                &GetDescendantsOptions::default(),
614            )
615            .await
616            .unwrap();
617        assert_eq!(result.descendants.len(), 3);
618
619        // With active-only filter: only D (B filtered out, so C is unreachable)
620        let req = GetDescendantsOptions {
621            status: vec![TenantStatus::Active],
622            ..Default::default()
623        };
624        let result = service
625            .get_descendants(&ctx, Uuid::parse_str(TENANT_A).unwrap(), &req)
626            .await
627            .unwrap();
628
629        assert_eq!(result.descendants.len(), 1);
630        assert_eq!(result.descendants[0].id, Uuid::parse_str(TENANT_D).unwrap());
631    }
632
633    #[tokio::test]
634    async fn get_descendants_pre_order() {
635        // Verify pre-order traversal: parent before children
636        // A -> B -> C
637        // Pre-order from A: B first, then C (B must come before its child C)
638        // Note: Sibling order is not guaranteed (HashMap), so we test a linear chain
639        let cfg = StaticTrPluginConfig {
640            tenants: vec![
641                tenant(TENANT_A, "Root", TenantStatus::Active),
642                tenant_with_parent(TENANT_B, "Child B", TENANT_A),
643                tenant_with_parent(TENANT_C, "Grandchild C", TENANT_B),
644            ],
645            ..Default::default()
646        };
647        let service = Service::from_config(&cfg);
648        let ctx = ctx_for_tenant(TENANT_A);
649
650        let result = service
651            .get_descendants(
652                &ctx,
653                Uuid::parse_str(TENANT_A).unwrap(),
654                &GetDescendantsOptions::default(),
655            )
656            .await
657            .unwrap();
658
659        assert_eq!(result.descendants.len(), 2);
660        // Pre-order guarantee: B comes before C (parent before child)
661        assert_eq!(result.descendants[0].id, Uuid::parse_str(TENANT_B).unwrap());
662        assert_eq!(result.descendants[1].id, Uuid::parse_str(TENANT_C).unwrap());
663    }
664
665    // ==================== is_ancestor tests ====================
666
667    #[tokio::test]
668    async fn is_ancestor_self_returns_false() {
669        let cfg = StaticTrPluginConfig {
670            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
671            ..Default::default()
672        };
673        let service = Service::from_config(&cfg);
674        let ctx = ctx_for_tenant(TENANT_A);
675        let a_id = Uuid::parse_str(TENANT_A).unwrap();
676
677        let result = service
678            .is_ancestor(&ctx, a_id, a_id, &IsAncestorOptions::default())
679            .await;
680        assert!(result.is_ok());
681        assert!(!result.unwrap());
682    }
683
684    #[tokio::test]
685    async fn is_ancestor_direct_parent() {
686        let cfg = StaticTrPluginConfig {
687            tenants: vec![
688                tenant(TENANT_A, "Root", TenantStatus::Active),
689                tenant_with_parent(TENANT_B, "Child", TENANT_A),
690            ],
691            ..Default::default()
692        };
693        let service = Service::from_config(&cfg);
694        let ctx = ctx_for_tenant(TENANT_A);
695
696        let a_id = Uuid::parse_str(TENANT_A).unwrap();
697        let b_id = Uuid::parse_str(TENANT_B).unwrap();
698
699        // A is ancestor of B
700        let result = service
701            .is_ancestor(&ctx, a_id, b_id, &IsAncestorOptions::default())
702            .await;
703        assert!(result.is_ok());
704        assert!(result.unwrap());
705
706        // B is NOT ancestor of A
707        let result = service
708            .is_ancestor(&ctx, b_id, a_id, &IsAncestorOptions::default())
709            .await;
710        assert!(result.is_ok());
711        assert!(!result.unwrap());
712    }
713
714    #[tokio::test]
715    async fn is_ancestor_with_barrier() {
716        // A -> B (barrier) -> C
717        let cfg = StaticTrPluginConfig {
718            tenants: vec![
719                tenant(TENANT_A, "Root", TenantStatus::Active),
720                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
721                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
722            ],
723            ..Default::default()
724        };
725        let service = Service::from_config(&cfg);
726        let ctx = ctx_for_tenant(TENANT_A);
727
728        let a_id = Uuid::parse_str(TENANT_A).unwrap();
729        let b_id = Uuid::parse_str(TENANT_B).unwrap();
730        let c_id = Uuid::parse_str(TENANT_C).unwrap();
731
732        // B is direct parent of C - allowed
733        let result = service
734            .is_ancestor(&ctx, b_id, c_id, &IsAncestorOptions::default())
735            .await;
736        assert!(result.unwrap());
737
738        // A blocked by barrier B
739        let result = service
740            .is_ancestor(&ctx, a_id, c_id, &IsAncestorOptions::default())
741            .await;
742        assert!(!result.unwrap());
743
744        // With BarrierMode::Ignore - A is ancestor of C
745        let req = IsAncestorOptions {
746            barrier_mode: BarrierMode::Ignore,
747        };
748        let result = service.is_ancestor(&ctx, a_id, c_id, &req).await;
749        assert!(result.unwrap());
750    }
751
752    #[tokio::test]
753    async fn is_ancestor_direct_barrier_child() {
754        // A -> B (barrier)
755        // is_ancestor(A, B) should return false because B is a barrier
756        let cfg = StaticTrPluginConfig {
757            tenants: vec![
758                tenant(TENANT_A, "Root", TenantStatus::Active),
759                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
760            ],
761            ..Default::default()
762        };
763        let service = Service::from_config(&cfg);
764        let ctx = ctx_for_tenant(TENANT_A);
765
766        let a_id = Uuid::parse_str(TENANT_A).unwrap();
767        let b_id = Uuid::parse_str(TENANT_B).unwrap();
768
769        // A is NOT ancestor of B when B is a barrier (default BarrierMode::Respect)
770        let result = service
771            .is_ancestor(&ctx, a_id, b_id, &IsAncestorOptions::default())
772            .await;
773        assert!(!result.unwrap());
774
775        // With BarrierMode::Ignore, A IS ancestor of B
776        let req = IsAncestorOptions {
777            barrier_mode: BarrierMode::Ignore,
778        };
779        let result = service.is_ancestor(&ctx, a_id, b_id, &req).await;
780        assert!(result.unwrap());
781    }
782
783    #[tokio::test]
784    async fn is_ancestor_nonexistent() {
785        let cfg = StaticTrPluginConfig {
786            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
787            ..Default::default()
788        };
789        let service = Service::from_config(&cfg);
790        let ctx = ctx_for_tenant(TENANT_A);
791
792        let a_id = Uuid::parse_str(TENANT_A).unwrap();
793        let nonexistent = Uuid::parse_str(NONEXISTENT).unwrap();
794
795        // Nonexistent ancestor
796        let result = service
797            .is_ancestor(&ctx, nonexistent, a_id, &IsAncestorOptions::default())
798            .await;
799        assert!(result.is_err());
800
801        // Nonexistent descendant
802        let result = service
803            .is_ancestor(&ctx, a_id, nonexistent, &IsAncestorOptions::default())
804            .await;
805        assert!(result.is_err());
806    }
807}