diff --git a/docs/README.md b/docs/README.md index 873d922..c1396e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ Look up exact behavior: Resource contracts and acceptance: - [Architecture resource coverage](reference/architecture-resource-coverage.md) +- [Domain-scoped listing](reference/domain-scoped-listing.md) - [Channel and DataStore contracts](reference/channel-and-datastore-contracts.md) - [Channel and DataStore acceptance fixture](reference/channel-and-datastore-acceptance.md) - [Specification and API contracts](reference/specification-and-api-contracts.md) diff --git a/docs/reference/architecture-resource-coverage.md b/docs/reference/architecture-resource-coverage.md index 95a178f..0e190bb 100644 --- a/docs/reference/architecture-resource-coverage.md +++ b/docs/reference/architecture-resource-coverage.md @@ -30,7 +30,7 @@ version strings do not replace these selectors. See | Area | Existing representation | Remaining boundary | | --- | --- | --- | -| Strategic membership | Domain project, Subdomain domain and context realizations are native references. | Arbitrary domain nesting and shared authored system/service membership have no dedicated fields. Domain listing derives membership from namespace and one realized subdomain; its maps omit versions. It does not prove exact-version or multi-domain membership. | +| Strategic membership | Domain project, Subdomain domain and context realizations are native references. [Domain-scoped listing](domain-scoped-listing.md) preserves exact strategic versions and considers every realization; ordinary entity scope remains namespace-derived. | Arbitrary domain nesting and shared authored system/service membership have no dedicated fields. Listing does not establish authored membership, change project-validation scope or add a Domain version selector to Studio. | | System and service relationships | Component members can select other Components and ExternalSystems, with ordinary reference discovery. | Dedicated grouping levels, labeled directed system relationships and system-level actor interactions require a contract decision. Containment is Component-owned; ExternalSystem has no members field, so external-system-owned membership also remains a contract decision. | | Communication | API roles/providers/usages, Processor calls and Channel bindings to actual observations are supported. | General authored send/receive and conditional-trigger declarations before behavioral mapping have no native relationship contract. Retained source declarations do not establish executable paths. | | Metadata and agents | Registered annotations include links, code repository/path/symbol/ref provenance, tags and lifecycle status. | Lifecycle effective dates and agent model/tool declarations need dedicated contracts. Arbitrary metadata references are not recursively indexed. See [annotations](../../proto/trogonatlas/annotation/v1alpha1/annotations.proto). | diff --git a/docs/reference/domain-scoped-listing.md b/docs/reference/domain-scoped-listing.md new file mode 100644 index 0000000..20198ef --- /dev/null +++ b/docs/reference/domain-scoped-listing.md @@ -0,0 +1,77 @@ +# Domain-scoped listing + +`ListEntitiesByDomain` lists native entities through their declared strategic +references. Its [request](../../proto/trogonatlas/api/eventmodel/v1alpha1/service.proto) +selects a Domain ID, optional entity kinds, pagination and +`latest_versions_only`. This is a query over the existing strategic model, +not a new system-membership or authorization mechanism. + +## Exact strategic selection + +Domain and Subdomain references use namespace, slug and numeric version. +Selecting a Domain does not select another version of that Domain. A context +realizing a Subdomain version does not fall back to a different version of +that Subdomain. + +| Returned entity | Membership rule | +| --- | --- | +| Domain | Its complete ID matches the requested Domain ID. | +| Subdomain | Its declared Domain reference matches the complete requested ID. | +| BoundedContext | At least one of its own realized Subdomain references resolves to a stored Subdomain declaring the requested Domain ID. | +| Other native entity | Its namespace has a BoundedContext realization chain to the requested Domain ID. | + +Every declared realization is considered. A missing Subdomain does not suppress +a different valid realization. Repeated references and multiple matching paths +do not duplicate returned entities. A context realizing subdomains in different +domains may contribute to each domain's result. + +Each BoundedContext version is checked against its own references. Other +entities retain namespace-derived scope across the visible context versions; +their numeric versions are not matched to a context's numeric version. +This scope does not establish Component containment, explicit service +membership, ownership or execution. + +## Filtering and pagination + +The optional kind filter restricts returned entity kinds. With +`latest_versions_only`, the result retains the greatest numeric version of each +matching `(kind, namespace, slug)` before pagination. Exact strategic reference +resolution still uses the pinned versions, including older Subdomain versions. +The flag does not retarget references or replace an explicitly selected Domain +with a newer Domain version. + +Pages use native `(kind, namespace, slug, version)` ordering. A page token +continues the same query; keep the Domain ID, filters, branch and authorization +context unchanged. Independent requests do not establish a historical +transaction snapshot if the model changes between pages. + +## Branch and client scope + +Queries use the current authorized branch view. Branch-only entities appear +only in that branch, and hidden namespaces cannot contribute returned entities +or realization paths. + +Branch visibility is checked independently of namespace visibility. An +inaccessible branch returns `NOT_FOUND`, even when it contains entities in a +namespace the caller may otherwise read. + +Resolution uses the existing capped model snapshot. An omitted intermediate +record can prevent a realization chain from resolving. Empty results or an +exhausted page token therefore do not establish whole-store completeness. + +MCP `list_entities_by_domain` requires an explicit positive Domain version and +defaults `latest_versions_only` to true. Studio's current `/api/by-domain` +route selects Domain version `1`; this backend query contract does not add a +version selector to that route. `ValidateProject` has a separate scope path +and is unchanged by this correction. + +## Executable coverage + +The [public transport regressions](../../rsworkspace/crates/trogon-atlas-server/tests/domain_query_membership.rs) +exercise exact Domain/Subdomain versions, context-specific realizations, +multiple and dangling realization paths, deduplication, latest matching +versions before filtered pagination, branch overlays, foreign-branch refusal +and owner visibility. +They use supported writes and authenticated gRPC against a real NATS Store. +Their presence alone is not a recorded passing run or proof of every +strategic-model acceptance case. diff --git a/rsworkspace/crates/trogon-atlas-server/src/service.rs b/rsworkspace/crates/trogon-atlas-server/src/service.rs index 97e7b42..e48f8f5 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/service.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/service.rs @@ -7501,6 +7501,13 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl ) -> Result, Status> { let vis = Self::visibility_of(&req); let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } let req = req.into_inner(); let dom_id = req .domain_id @@ -7515,8 +7522,37 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl let all = self.snapshot_for(branch.as_deref(), &vis).await?; - let (subdomain_domain, ns_to_dom) = build_domain_maps(all.as_ref()); - let target = (dom_id.namespace.clone(), dom_id.slug.clone()); + let target = pb::IdKey::new(&dom_id); + let subdomains: std::collections::HashSet = all + .iter() + .filter_map(|se| { + let Some(pb::entity::Kind::Subdomain(subdomain)) = se.entity.kind.as_ref() else { + return None; + }; + let domain_id = subdomain.domain.as_ref()?.id.as_ref()?; + (pb::IdKey::new(domain_id) == target) + .then(|| subdomain.id.as_ref().map(pb::IdKey::new)) + .flatten() + }) + .collect(); + let contexts: std::collections::HashSet = all + .iter() + .filter_map(|se| { + let Some(pb::entity::Kind::BoundedContext(context)) = se.entity.kind.as_ref() + else { + return None; + }; + context + .realizes + .iter() + .filter_map(|reference| reference.id.as_ref()) + .any(|id| subdomains.contains(&pb::IdKey::new(id))) + .then(|| context.id.as_ref().map(pb::IdKey::new)) + .flatten() + }) + .collect(); + let namespaces: std::collections::HashSet<&str> = + contexts.iter().map(|id| id.namespace.as_str()).collect(); let mut matched: Vec = Vec::new(); for se in all.iter() { @@ -7529,22 +7565,34 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl if !kinds.is_empty() && !kinds.contains(&(k as i32)) { continue; } - // The Domain itself, its Subdomains, and its BCs always belong; - // everything else has to chain back via the BC namespace. let belongs = match k { - pb::EntityKind::Domain => id.namespace == target.0 && id.slug == target.1, - pb::EntityKind::Subdomain => subdomain_domain - .get(&(id.namespace.clone(), id.slug.clone())) - .is_some_and(|d| d == &target), - pb::EntityKind::BoundedContext => { - ns_to_dom.get(&id.namespace).is_some_and(|d| d == &target) - } - _ => ns_to_dom.get(&id.namespace).is_some_and(|d| d == &target), + pb::EntityKind::Domain => pb::IdKey::new(id) == target, + pb::EntityKind::Subdomain => subdomains.contains(&pb::IdKey::new(id)), + pb::EntityKind::BoundedContext => contexts.contains(&pb::IdKey::new(id)), + _ => namespaces.contains(id.namespace.as_str()), }; if belongs { matched.push(se.entity.clone()); } } + if req.latest_versions_only { + let mut latest = std::collections::HashMap::new(); + for entity in &matched { + let (Ok(kind), Ok(id)) = (entity_kind(entity), entity_id(entity)) else { + continue; + }; + let version = latest + .entry((kind, id.namespace.clone(), id.slug.clone())) + .or_insert(id.version); + *version = (*version).max(id.version); + } + matched.retain(|entity| { + let (Ok(kind), Ok(id)) = (entity_kind(entity), entity_id(entity)) else { + return false; + }; + latest.get(&(kind, id.namespace.clone(), id.slug.clone())) == Some(&id.version) + }); + } let total = matched.len(); let entities: Vec = matched.into_iter().skip(offset).take(page_size).collect(); let next_offset = offset + entities.len(); diff --git a/rsworkspace/crates/trogon-atlas-server/tests/domain_query_membership.rs b/rsworkspace/crates/trogon-atlas-server/tests/domain_query_membership.rs new file mode 100644 index 0000000..7044e0e --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/domain_query_membership.rs @@ -0,0 +1,502 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod support; + +use std::{collections::HashSet, sync::Arc}; + +use support::two_tenants::{ + authed, authed_branch, serve_with_registry, ACME_TOKEN, BETA_TOKEN, ROOT_TOKEN, +}; +use tonic::transport::Channel; +use trogon_atlas_core::refs::{entity_id, entity_kind}; +use trogon_atlas_proto::{self as pb, event_model_service_client::EventModelServiceClient}; +use trogon_atlas_server::{ + auth::{RegistryFile, TokenRegistry}, + metrics::ServerMetrics, + service::EventModelServiceImpl, +}; + +type Client = EventModelServiceClient; + +async fn client() -> Client { + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let svc = EventModelServiceImpl::try_new(store, ServerMetrics::default()).unwrap(); + let registry: RegistryFile = toml::from_str(&format!( + r#" +[principals.acme] +role = "writer" +tokens = ["{ACME_TOKEN}"] +parent = "acme" + +[principals.beta] +role = "writer" +tokens = ["{BETA_TOKEN}"] +parent = "beta" + +[principals.root] +role = "admin" +tokens = ["{ROOT_TOKEN}"] +"# + )) + .unwrap(); + serve_with_registry(svc, TokenRegistry::from_file_contents(registry).unwrap()).await +} + +async fn namespace(c: &mut Client, name: &str, token: &str) -> String { + c.register_namespace(authed( + pb::RegisterNamespaceRequest { + name: name.into(), + parent: String::new(), + }, + token, + )) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id +} + +fn id(ns: &str, slug: &str, version: u64) -> pb::Id { + pb::Id { + namespace: ns.into(), + slug: slug.into(), + version, + } +} + +fn domain(id: &pb::Id) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::Domain(pb::Domain { + id: Some(id.clone()), + title: "Domain".into(), + ..Default::default() + })), + ..Default::default() + } +} + +fn subdomain(id: &pb::Id, domain: &pb::Id) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::Subdomain(pb::Subdomain { + id: Some(id.clone()), + title: "Subdomain".into(), + domain: Some(pb::DomainRef { + id: Some(domain.clone()), + }), + ..Default::default() + })), + ..Default::default() + } +} + +fn context(ns: &str, version: u64, realizes: &[pb::Id]) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::BoundedContext(pb::BoundedContext { + id: Some(id(ns, ns, version)), + title: "Context".into(), + realizes: realizes + .iter() + .map(|id| pb::SubdomainRef { + id: Some(id.clone()), + }) + .collect(), + ..Default::default() + })), + ..Default::default() + } +} + +fn event(ns: &str, slug: &str, version: u64) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::Event(pb::Event { + id: Some(id(ns, slug, version)), + title: slug.into(), + ..Default::default() + })), + ..Default::default() + } +} + +async fn put(c: &mut Client, entity: pb::Entity, branch: Option<&str>) { + let body = pb::PutEntityRequest { + entity: Some(entity), + ..Default::default() + }; + let req = match branch { + Some(branch) => authed_branch(body, ROOT_TOKEN, branch), + None => authed(body, ROOT_TOKEN), + }; + c.put_entity(req).await.unwrap(); +} + +fn query(domain: &pb::Id) -> pb::ListEntitiesByDomainRequest { + pb::ListEntitiesByDomainRequest { + domain_id: Some(domain.clone()), + ..Default::default() + } +} + +fn keys(entities: &[pb::Entity]) -> HashSet { + entities + .iter() + .map(|e| pb::EntityKey::new(entity_kind(e).unwrap(), entity_id(e).unwrap())) + .collect() +} + +async fn listed(c: &mut Client, domain: &pb::Id) -> HashSet { + keys( + &c.list_entities_by_domain(authed(query(domain), ROOT_TOKEN)) + .await + .unwrap() + .into_inner() + .entities, + ) +} + +#[tokio::test(flavor = "multi_thread")] +async fn domain_and_subdomain_membership_preserve_exact_versions() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", ROOT_TOKEN).await; + let d1 = id(&ns, "domain", 1); + let d2 = id(&ns, "domain", 2); + let s1 = id(&ns, "subdomain", 1); + let s2 = id(&ns, "subdomain", 2); + let expected = vec![domain(&d1), subdomain(&s1, &d1)]; + for e in expected + .iter() + .cloned() + .chain([domain(&d2), subdomain(&s2, &d2)]) + { + put(&mut c, e, None).await; + } + assert_eq!(listed(&mut c, &d1).await, keys(&expected)); + assert_eq!( + listed(&mut c, &d2).await, + keys(&[domain(&d2), subdomain(&s2, &d2)]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn context_uses_its_own_exact_realizes_not_other_context_versions() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", ROOT_TOKEN).await; + let bc = namespace(&mut c, "implementation", ROOT_TOKEN).await; + let d1 = id(&ns, "one", 1); + let d2 = id(&ns, "two", 1); + let s1 = id(&ns, "subdomain", 1); + let s2 = id(&ns, "subdomain", 2); + for e in [ + domain(&d1), + domain(&d2), + subdomain(&s1, &d1), + subdomain(&s2, &d2), + context(&bc, 1, std::slice::from_ref(&s1)), + context(&bc, 2, std::slice::from_ref(&s2)), + event(&bc, "ordinary", 7), + ] { + put(&mut c, e, None).await; + } + for (d, s, version) in [(&d1, &s1, 1), (&d2, &s2, 2)] { + assert_eq!( + listed(&mut c, d).await, + keys(&[ + domain(d), + subdomain(s, d), + context(&bc, version, std::slice::from_ref(s)), + event(&bc, "ordinary", 7) + ]) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn all_realized_subdomains_contribute_and_shared_membership_is_unique() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", ROOT_TOKEN).await; + let bc = namespace(&mut c, "implementation", ROOT_TOKEN).await; + let other_bc = namespace(&mut c, "other", ROOT_TOKEN).await; + let d1 = id(&ns, "one", 1); + let d2 = id(&ns, "two", 1); + let s1 = id(&ns, "one", 1); + let s2 = id(&ns, "two", 1); + let realizes = [s1.clone(), s2.clone(), s2.clone()]; + let dangling = [id(&ns, "missing", 9), s2.clone()]; + for e in [ + domain(&d1), + domain(&d2), + subdomain(&s1, &d1), + subdomain(&s2, &d2), + context(&bc, 1, &realizes), + context(&other_bc, 1, &dangling), + event(&bc, "ordinary", 3), + event(&other_bc, "ordinary", 5), + ] { + put(&mut c, e, None).await; + } + let actual = c + .list_entities_by_domain(authed(query(&d2), ROOT_TOKEN)) + .await + .unwrap() + .into_inner() + .entities; + assert_eq!( + keys(&actual), + keys(&[ + domain(&d2), + subdomain(&s2, &d2), + context(&bc, 1, &realizes), + context(&other_bc, 1, &dangling), + event(&bc, "ordinary", 3), + event(&other_bc, "ordinary", 5) + ]) + ); + assert_eq!(actual.len(), keys(&actual).len()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn latest_matching_versions_are_selected_before_kind_filtered_pagination() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", ROOT_TOKEN).await; + let bc = namespace(&mut c, "implementation", ROOT_TOKEN).await; + let d1 = id(&ns, "domain", 1); + let d2 = id(&ns, "domain", 2); + let s1 = id(&ns, "subdomain", 1); + let s2 = id(&ns, "subdomain", 2); + for e in [ + domain(&d1), + domain(&d2), + subdomain(&s1, &d1), + subdomain(&s2, &d2), + context(&bc, 1, std::slice::from_ref(&s1)), + context(&bc, 2, std::slice::from_ref(&s2)), + event(&bc, "alpha", 2), + event(&bc, "alpha", 10), + event(&bc, "beta", 3), + ] { + put(&mut c, e, None).await; + } + let mut q = query(&d1); + q.latest_versions_only = true; + let all = c + .list_entities_by_domain(authed(q.clone(), ROOT_TOKEN)) + .await + .unwrap() + .into_inner() + .entities; + assert_eq!( + keys(&all), + keys(&[ + domain(&d1), + subdomain(&s1, &d1), + context(&bc, 1, std::slice::from_ref(&s1)), + event(&bc, "alpha", 10), + event(&bc, "beta", 3) + ]) + ); + q.kinds = vec![pb::EntityKind::Event as i32]; + q.page_size = 1; + let first = c + .list_entities_by_domain(authed(q.clone(), ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&first.entities), keys(&[event(&bc, "alpha", 10)])); + assert_ne!(first.next_page_token, ""); + q.page_token = first.next_page_token; + let second = c + .list_entities_by_domain(authed(q, ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&second.entities), keys(&[event(&bc, "beta", 3)])); + assert_eq!(second.next_page_token, ""); +} + +#[tokio::test(flavor = "multi_thread")] +async fn latest_flag_deduplicates_versions_without_affecting_unfiltered_results() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", ROOT_TOKEN).await; + let bc = namespace(&mut c, "implementation", ROOT_TOKEN).await; + let d = id(&ns, "domain", 1); + let s = id(&ns, "subdomain", 1); + for e in [ + domain(&d), + subdomain(&s, &d), + context(&bc, 1, &[s]), + event(&bc, "alpha", 2), + event(&bc, "alpha", 10), + event(&bc, "beta", 3), + ] { + put(&mut c, e, None).await; + } + let mut q = query(&d); + q.kinds = vec![pb::EntityKind::Event as i32]; + let unfiltered = c + .list_entities_by_domain(authed(q.clone(), ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!( + keys(&unfiltered.entities), + keys(&[ + event(&bc, "alpha", 2), + event(&bc, "alpha", 10), + event(&bc, "beta", 3) + ]) + ); + q.latest_versions_only = true; + q.page_size = 1; + let first = c + .list_entities_by_domain(authed(q.clone(), ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&first.entities), keys(&[event(&bc, "alpha", 10)])); + assert_ne!(first.next_page_token, ""); + q.page_token = first.next_page_token; + let second = c + .list_entities_by_domain(authed(q, ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&second.entities), keys(&[event(&bc, "beta", 3)])); + assert_eq!(second.next_page_token, ""); +} + +#[tokio::test(flavor = "multi_thread")] +async fn foreign_branch_cannot_disclose_staged_entities_owned_by_the_caller() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", BETA_TOKEN).await; + let bc = namespace(&mut c, "implementation", BETA_TOKEN).await; + let d = id(&ns, "domain", 1); + let s = id(&ns, "subdomain", 1); + let baseline = [ + domain(&d), + subdomain(&s, &d), + context(&bc, 1, std::slice::from_ref(&s)), + ]; + for entity in &baseline { + put(&mut c, entity.clone(), None).await; + } + let staged = event(&bc, "unmerged", 1); + let mut expected = baseline.to_vec(); + expected.push(staged.clone()); + for branch in ["@acme:membership", "@beta:membership"] { + c.create_branch(authed( + pb::CreateBranchRequest { + name: branch.into(), + doc: String::new(), + }, + ROOT_TOKEN, + )) + .await + .unwrap(); + put(&mut c, staged.clone(), Some(branch)).await; + let root = c + .list_entities_by_domain(authed_branch(query(&d), ROOT_TOKEN, branch)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&root.entities), keys(&expected)); + } + let beta_baseline = c + .list_entities_by_domain(authed(query(&d), BETA_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&beta_baseline.entities), keys(&baseline)); + let beta_branch = c + .list_entities_by_domain(authed_branch(query(&d), BETA_TOKEN, "@beta:membership")) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&beta_branch.entities), keys(&expected)); + + let denied = c + .list_entities_by_domain(authed_branch(query(&d), BETA_TOKEN, "@acme:membership")) + .await + .expect_err("Beta must not read its unmerged entities through Acme's branch"); + assert_eq!(denied.code(), tonic::Code::NotFound); + assert_eq!(denied.message(), "branch not found"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn branch_overlay_and_owner_visibility_bound_membership() { + let mut c = client().await; + let ns = namespace(&mut c, "strategy", ACME_TOKEN).await; + let bc = namespace(&mut c, "implementation", ACME_TOKEN).await; + let hidden = namespace(&mut c, "hidden", BETA_TOKEN).await; + let d = id(&ns, "domain", 1); + let s = id(&ns, "subdomain", 1); + for e in [ + domain(&d), + subdomain(&s, &d), + context(&bc, 1, std::slice::from_ref(&s)), + context(&hidden, 1, std::slice::from_ref(&s)), + event(&hidden, "private", 1), + ] { + put(&mut c, e, None).await; + } + c.create_branch(authed( + pb::CreateBranchRequest { + name: "@acme:membership".into(), + doc: String::new(), + }, + ROOT_TOKEN, + )) + .await + .unwrap(); + put( + &mut c, + event(&bc, "branch-only", 4), + Some("@acme:membership"), + ) + .await; + let baseline = c + .list_entities_by_domain(authed(query(&d), ACME_TOKEN)) + .await + .unwrap() + .into_inner() + .entities; + let expected = [ + domain(&d), + subdomain(&s, &d), + context(&bc, 1, std::slice::from_ref(&s)), + ]; + assert_eq!(keys(&baseline), keys(&expected)); + let branch = c + .list_entities_by_domain(authed_branch(query(&d), ACME_TOKEN, "@acme:membership")) + .await + .unwrap() + .into_inner() + .entities; + let mut expected = expected.to_vec(); + expected.push(event(&bc, "branch-only", 4)); + assert_eq!(keys(&branch), keys(&expected)); + + put(&mut c, context(&bc, 1, &[]), Some("@acme:membership")).await; + let disconnected = c + .list_entities_by_domain(authed_branch(query(&d), ACME_TOKEN, "@acme:membership")) + .await + .unwrap() + .into_inner(); + assert_eq!( + keys(&disconnected.entities), + keys(&[domain(&d), subdomain(&s, &d)]) + ); + let unchanged = c + .list_entities_by_domain(authed(query(&d), ACME_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(keys(&unchanged.entities), keys(&baseline)); + + let hidden = c + .list_entities_by_domain(authed(query(&d), BETA_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(hidden.entities, Vec::::new()); +}