LCOV - code coverage report
Current view: top level - antares-api/src - distsub.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 95.0 % 1479 1405
Test Date: 2026-11-02 13:21:27 Functions: 61.6 % 391 241

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The CONSUMER half of distributed subscriptions (5.8.1.4 / 5.8.2.4 /
       3              : //! 5.8.5.4): an entity Subscription with localOnly != true creates an
       4              : //! internal Context Source Registration Subscription (5.11.2) whose
       5              : //! CSource notifications drive per-registration remote subscription
       6              : //! create/update/delete (triggerReason newlyMatching / updated /
       7              : //! noLongerMatching), with subscriptionId mappings stored so inbound
       8              : //! remote notifications forward to the original subscriber.
       9              : 
      10              : use crate::negotiate::{ApiError, ApiResult};
      11              : use crate::state::{now_iso, AppState};
      12              : use antares_model::{NgsiError, TenantId};
      13              : use antares_store::CurrentStateDriverExt;
      14              : use antares_store::Kind;
      15              : use axum::body::Bytes;
      16              : use axum::extract::State;
      17              : use axum::http::{HeaderMap, StatusCode};
      18              : use axum::response::{IntoResponse, Response};
      19              : use serde_json::{json, Value};
      20              : 
      21          270 : fn ds_index_tenant() -> Option<TenantId> {
      22          270 :     TenantId::new_internal("distsub-index").ok()
      23          270 : }
      24              : 
      25         2020 : async fn ds_get(st: &AppState, tenant: &TenantId, own_id: &str) -> Value {
      26         2020 :     st.store
      27         2020 :         .get(tenant, Kind::DistSub, own_id)
      28         2020 :         .await
      29         2020 :         .ok()
      30         2020 :         .flatten()
      31         2020 :         .unwrap_or_else(|| json!({}))
      32         2020 : }
      33              : 
      34         1002 : async fn ds_put(st: &AppState, tenant: &TenantId, own_id: &str, doc: Value) {
      35         1002 :     let updated = st
      36         1002 :         .store
      37         1002 :         .mutate(tenant, Kind::DistSub, own_id, |d| {
      38            0 :             *d = doc.clone();
      39            0 :             Ok::<_, std::convert::Infallible>(())
      40            0 :         })
      41         1002 :         .await
      42         1002 :         .ok()
      43         1002 :         .flatten()
      44         1002 :         .is_some();
      45         1002 :     if !updated {
      46         1002 :         if let Err(e) = st.store.create(tenant, Kind::DistSub, own_id, doc).await {
      47            0 :             tracing::warn!("subscription {own_id}: distributed mapping not stored: {e}");
      48         1002 :         }
      49            0 :     }
      50         1002 : }
      51              : 
      52              : /// remotes of one own Subscription: reg id → (endpoint, remote sub id)
      53          886 : fn ds_remotes(doc: &Value) -> Vec<(String, (String, String))> {
      54          886 :     doc.get("remotes")
      55          886 :         .and_then(Value::as_object)
      56          886 :         .map(|m| {
      57          130 :             m.iter()
      58          146 :                 .filter_map(|(reg, v)| {
      59              :                     Some((
      60          146 :                         reg.clone(),
      61              :                         (
      62          146 :                             v.get(0)?.as_str()?.to_owned(),
      63          134 :                             v.get(1)?.as_str()?.to_owned(),
      64              :                         ),
      65              :                     ))
      66          146 :                 })
      67          130 :                 .collect()
      68          130 :         })
      69          886 :         .unwrap_or_default()
      70          886 : }
      71              : 
      72              : /// Insert or remove ONE `remotes` entry under the store's own lock, and
      73              : /// only if the document still exists. A targeted mutate, never a
      74              : /// read-modify-write of the whole document: the per-registration branches of
      75              : /// 5.8.1.4 interleave at their forward `await`s, so a full-document write
      76              : /// would drop a sibling registration's mapping, and a Delete Subscription
      77              : /// (5.8.5.4) that lands mid-forward would be undone by a write that
      78              : /// resurrects the deleted document. `false` = the Subscription's mapping
      79              : /// document is gone, or the registration already holds a remote subscription
      80              : /// — 5.8.1.4 stores ONE subscriptionId per Context Source Registration, and
      81              : /// two notifications racing for the same pair would otherwise each create a
      82              : /// remote copy and orphan the loser's at the source. That check runs inside
      83              : /// the closure the store executes under its write lock, so the same lock
      84              : /// decides and acts.
      85          136 : async fn ds_set_remote(
      86          136 :     st: &AppState,
      87          136 :     tenant: &TenantId,
      88          136 :     own_id: &str,
      89          136 :     reg_id: &str,
      90          136 :     entry: Option<Value>,
      91          136 : ) -> bool {
      92          136 :     st.store
      93          136 :         .mutate(tenant, Kind::DistSub, own_id, |d| {
      94          132 :             if let Some(o) = d.as_object_mut() {
      95          132 :                 match &entry {
      96          116 :                     Some(v) => {
      97          116 :                         if !o.get("remotes").is_some_and(Value::is_object) {
      98          108 :                             o.insert("remotes".into(), json!({}));
      99          108 :                         }
     100          116 :                         if let Some(m) = o.get_mut("remotes").and_then(Value::as_object_mut) {
     101          116 :                             if m.contains_key(reg_id) {
     102            4 :                                 return Err(());
     103          112 :                             }
     104          112 :                             m.insert(reg_id.to_owned(), v.clone());
     105            0 :                         }
     106              :                     }
     107              :                     None => {
     108           16 :                         if let Some(m) = o.get_mut("remotes").and_then(Value::as_object_mut) {
     109           16 :                             m.remove(reg_id);
     110           16 :                         }
     111              :                     }
     112              :                 }
     113            0 :             }
     114          128 :             Ok(())
     115          132 :         })
     116          136 :         .await
     117          136 :         .ok()
     118          136 :         .flatten()
     119          136 :         .is_some_and(|r: Result<(), ()>| r.is_ok())
     120          136 : }
     121              : 
     122          120 : async fn inbound_put(st: &AppState, remote_id: &str, tenant: &TenantId, own_id: &str) {
     123          120 :     if let Some(idx) = ds_index_tenant() {
     124              :         // Without this index every notification the source sends is answered
     125              :         // 404 and the subscription silently never notifies — a store failure
     126              :         // here has to be visible to an operator.
     127          120 :         if let Err(e) = st
     128          120 :             .store
     129          120 :             .create(
     130          120 :                 &idx,
     131          120 :                 Kind::DistSub,
     132          120 :                 remote_id,
     133          120 :                 json!({"tenant": tenant.as_str(), "own": own_id}),
     134          120 :             )
     135          120 :             .await
     136              :         {
     137              :             // Not the remote id: it is the key /ex/v1/remote-notify accepts
     138              :             // a notification by, and a log line is no place for it.
     139            0 :             tracing::warn!("subscription {own_id}: inbound mapping not stored: {e}");
     140          120 :         }
     141            0 :     }
     142          120 : }
     143              : 
     144          102 : async fn inbound_get(st: &AppState, remote_id: &str) -> Option<(String, String)> {
     145          102 :     let idx = ds_index_tenant()?;
     146          102 :     let doc = st
     147          102 :         .store
     148          102 :         .get(&idx, Kind::DistSub, remote_id)
     149          102 :         .await
     150          102 :         .ok()
     151          102 :         .flatten()?;
     152              :     Some((
     153           74 :         doc.get("tenant")?.as_str()?.to_owned(),
     154           74 :         doc.get("own")?.as_str()?.to_owned(),
     155              :     ))
     156          102 : }
     157              : 
     158              : impl AppState {
     159              :     /// The Tenant whose distributed Subscription receives the notifications
     160              :     /// of the copy `subscription_id` (5.8.1.4), read from this broker's own
     161              :     /// inbound index, never from the notification.
     162            4 :     pub async fn notification_receiver(&self, subscription_id: &str) -> Option<String> {
     163            4 :         inbound_get(self, subscription_id)
     164            4 :             .await
     165            4 :             .map(|(tenant, _)| tenant)
     166            4 :     }
     167              : }
     168              : 
     169           48 : async fn inbound_delete(st: &AppState, remote_id: &str) {
     170           48 :     if let Some(idx) = ds_index_tenant() {
     171           48 :         let _ = st.store.delete(&idx, Kind::DistSub, remote_id).await;
     172            0 :     }
     173           48 : }
     174              : 
     175         1182 : fn distributed(sub: &Value) -> bool {
     176         1182 :     sub.get("localOnly").and_then(Value::as_bool) != Some(true)
     177         1182 : }
     178              : 
     179              : /// 6.3.17/6.3.18: the Via chain the Subscription arrived with, rebuilt as
     180              : /// the header the outbound forward extends (`federation::forward` appends
     181              : /// this broker's alias). Stored on the Subscription as the broker-internal
     182              : /// `__via` member (`__context` is the precedent) by create.
     183         1082 : fn sub_via_headers(sub: &Value) -> HeaderMap {
     184         1082 :     let mut h = HeaderMap::new();
     185         1082 :     if let Some(v) = sub
     186         1082 :         .get("__via")
     187         1082 :         .and_then(Value::as_str)
     188         1082 :         .and_then(|v| axum::http::HeaderValue::from_str(v).ok())
     189           52 :     {
     190           52 :         h.insert("via", v);
     191         1030 :     }
     192         1082 :     h
     193         1082 : }
     194              : 
     195              : /// The Subscription members 5.11.2.4 matches a Context Source Registration
     196              : /// on, plus the @context the internal Registration Subscription is read
     197              : /// under. Kept in one place: create and update must offer the very same
     198              : /// document, or an update silently widens which sources are matched.
     199              : const CSR_MATCH_MEMBERS: [&str; 6] = [
     200              :     "entities",
     201              :     "watchedAttributes",
     202              :     "csf",
     203              :     "geoQ",
     204              :     "scopeQ",
     205              :     "temporalQ",
     206              : ];
     207              : 
     208              : /// 5.8.1.4: "Based on the content of the Subscription, a Context Source
     209              : /// Registration Subscription shall be created (clause 5.11.2)" — internal,
     210              : /// with the urn:antares:distsub endpoint handled in-process by notify.
     211          974 : pub(crate) async fn on_subscription_created(st: &AppState, tenant: &TenantId, sub: &Value) {
     212          974 :     if !distributed(sub) {
     213            8 :         return;
     214          966 :     }
     215              :     // 6.3.18 ("to avoid infinite loops"): a forwarded copy whose Via chain
     216              :     // already names this broker has come full circle — it serves locally,
     217              :     // and the distributed half is not created, so mutually registered
     218              :     // brokers cannot re-forward copies of copies without bound. via_loop
     219              :     // also enforces the MAX_VIA_HOPS ceiling on a forged chain.
     220          966 :     if crate::federation::via_loop(
     221          966 :         &sub_via_headers(sub),
     222          966 :         &crate::federation::alias_for(&st.host_alias, tenant),
     223              :     ) {
     224           12 :         return;
     225          954 :     }
     226          954 :     let Some(own_id) = sub.get("id").and_then(Value::as_str) else {
     227            0 :         return;
     228              :     };
     229          954 :     let csr_id = format!(
     230              :         "{}{}",
     231              :         crate::registry::INTERNAL_CSR_PREFIX,
     232          954 :         uuid::Uuid::new_v4()
     233              :     );
     234          954 :     let ts = now_iso();
     235          954 :     let mut doc = json!({
     236          954 :         "id": csr_id,
     237          954 :         "type": "Subscription",
     238          954 :         "isActive": true,
     239          954 :         "status": "active",
     240          954 :         "createdAt": ts,
     241          954 :         "modifiedAt": ts,
     242          954 :         "notification": {"endpoint": {"uri":
     243          954 :             format!("urn:antares:distsub:{}\n{own_id}", tenant.as_str())}},
     244              :     });
     245              :     // 5.11.2.4 matches a registration on all of these, so the Registration
     246              :     // Subscription carries every member that decides the match — a copy
     247              :     // built from the entity selectors alone offers the Subscription to
     248              :     // sources the subscriber's csf, geoQ, scopeQ or temporalQ excluded.
     249              :     // `q` is deliberately absent: on a Context Source Registration
     250              :     // Subscription it would filter registration properties, not Entity
     251              :     // Attributes.
     252          954 :     if let Some(o) = doc.as_object_mut() {
     253         6678 :         for k in CSR_MATCH_MEMBERS.iter().chain(["__context"].iter()) {
     254         6678 :             if let Some(v) = sub.get(k) {
     255         2070 :                 o.insert((*k).to_owned(), v.clone());
     256         4608 :             }
     257              :         }
     258            0 :     }
     259           60 :     if let (Some(a), Some(n)) = (
     260          954 :         sub.get("notification").and_then(|n| n.get("attributes")),
     261          954 :         doc.get_mut("notification").and_then(Value::as_object_mut),
     262           60 :     ) {
     263           60 :         n.insert("attributes".into(), a.clone());
     264          894 :     }
     265          954 :     let created = st
     266          954 :         .store
     267          954 :         .create(tenant, Kind::DistSub, &csr_id, doc)
     268          954 :         .await
     269          954 :         .unwrap_or_else(|e| {
     270            0 :             tracing::warn!("subscription {own_id}: Registration Subscription not created: {e}");
     271            0 :             false
     272            0 :         });
     273          954 :     if created {
     274          954 :         let mut doc = ds_get(st, tenant, own_id).await;
     275          954 :         if let Some(o) = doc.as_object_mut() {
     276          954 :             o.insert("csr_sub".into(), Value::String(csr_id.clone()));
     277          954 :         }
     278          954 :         ds_put(st, tenant, own_id, doc).await;
     279              :         // 5.11.2.4 initial notification with all matching registrations —
     280              :         // this is what turns already-known registrations into newlyMatching
     281          954 :         let (st2, t2) = (st.clone(), tenant.clone());
     282          954 :         crate::spawn(async move {
     283          882 :             crate::notify::csource_initial(&st2, &t2, &csr_id).await;
     284          878 :         });
     285            0 :     }
     286          974 : }
     287              : 
     288              : /// 5.8.2.4: keep the internal CSR subscription in step and forward reduced
     289              : /// updates to every mapped remote supporting updateSubscription (5.11.3).
     290           92 : pub(crate) async fn on_subscription_updated(st: &AppState, tenant: &TenantId, own_id: &str) {
     291           92 :     let Some(sub) = st
     292           92 :         .store
     293           92 :         .get(tenant, Kind::Subscription, own_id)
     294           92 :         .await
     295           92 :         .ok()
     296           92 :         .flatten()
     297              :     else {
     298            0 :         return;
     299              :     };
     300              :     // 5.8.1.4 gates the distributed half on "If localOnly=false": a
     301              :     // Subscription updated to localOnly=true is torn down like a deleted
     302              :     // one — the internal Registration Subscription goes, and every remote
     303              :     // copy already created is deleted at its source.
     304           92 :     if !distributed(&sub) {
     305            4 :         on_subscription_deleted(st, tenant, own_id).await;
     306            4 :         return;
     307           88 :     }
     308           88 :     let csr_id = ds_get(st, tenant, own_id)
     309           88 :         .await
     310           88 :         .get("csr_sub")
     311           88 :         .and_then(Value::as_str)
     312           88 :         .map(str::to_owned);
     313              :     // the initial Context Source Notification of the updated Registration
     314              :     // Subscription, started once the mapped copies are read below
     315           88 :     let mut initial: Option<String> = None;
     316           88 :     match csr_id {
     317              :         None => {
     318              :             // became distributed only now (e.g. localOnly flipped off)
     319            0 :             on_subscription_created(st, tenant, &sub).await;
     320              :         }
     321           88 :         Some(csr_id) => {
     322           88 :             let _ = st
     323           88 :                 .store
     324           88 :                 .mutate(tenant, Kind::DistSub, &csr_id, |doc| {
     325              :                     // The mapping comes out of the store, and `Value`'s index
     326              :                     // panics on anything that is not an object — inside a
     327              :                     // closure `mutate` runs holding the write lock, so the shape
     328              :                     // is decided once, here, and a mapping that is not one is
     329              :                     // left exactly as it was found.
     330           88 :                     let Some(map) = doc.as_object_mut() else {
     331           16 :                         return Ok(());
     332              :                     };
     333          432 :                     for k in CSR_MATCH_MEMBERS {
     334          432 :                         match sub.get(k) {
     335           76 :                             Some(v) => {
     336           76 :                                 map.insert(k.to_owned(), v.clone());
     337           76 :                             }
     338          356 :                             None => {
     339          356 :                                 map.remove(k);
     340          356 :                             }
     341              :                         }
     342              :                     }
     343           72 :                     let attrs = sub.get("notification").and_then(|n| n.get("attributes"));
     344           72 :                     match map.get_mut("notification").and_then(Value::as_object_mut) {
     345           64 :                         Some(n) => match attrs {
     346            4 :                             Some(a) => {
     347            4 :                                 n.insert("attributes".to_owned(), a.clone());
     348            4 :                             }
     349           60 :                             None => {
     350           60 :                                 n.remove("attributes");
     351           60 :                             }
     352              :                         },
     353              :                         // no notification object to carry them: the copy this
     354              :                         // mapping stands for is the one create built, so a
     355              :                         // mapping without it is not one an update can complete
     356            8 :                         None => return Ok(()),
     357              :                     }
     358           64 :                     map.insert("modifiedAt".to_owned(), Value::String(now_iso()));
     359           64 :                     Ok::<(), NgsiError>(())
     360           88 :                 })
     361           88 :                 .await;
     362              :             // 5.11.3.4 after the update: every registration matching now is
     363              :             // notified, so one the update newly matches gets its copy
     364              :             // (newlyMatching); one already mapped is left to the PATCH below
     365           88 :             initial = Some(csr_id);
     366              :         }
     367              :     }
     368              :     // the updated Registration Subscription decides which mapped copies
     369              :     // still match
     370           88 :     let csr = match ds_get(st, tenant, own_id)
     371           88 :         .await
     372           88 :         .get("csr_sub")
     373           88 :         .and_then(Value::as_str)
     374              :     {
     375           88 :         Some(csr_id) => st
     376           88 :             .store
     377           88 :             .get(tenant, Kind::DistSub, csr_id)
     378           88 :             .await
     379           88 :             .ok()
     380           88 :             .flatten(),
     381            0 :         None => None,
     382              :     };
     383           88 :     let csr_ctx = match &csr {
     384           88 :         Some(c) => Some(crate::notify::sub_context(st, tenant, c).await),
     385            0 :         None => None,
     386              :     };
     387           88 :     let remotes: Vec<(String, (String, String))> = ds_remotes(&ds_get(st, tenant, own_id).await);
     388              :     // Read before the initial notification runs: a registration it maps now
     389              :     // is getting its copy by POST, and a PATCH raced against that create
     390              :     // reaches a remote subscription that may not exist yet.
     391           88 :     if let Some(csr_id) = initial {
     392           88 :         let (st2, t2) = (st.clone(), tenant.clone());
     393           88 :         crate::spawn(async move {
     394           56 :             crate::notify::csource_initial(&st2, &t2, &csr_id).await;
     395           56 :         });
     396            0 :     }
     397           88 :     for (reg_id, (endpoint, remote_id)) in remotes {
     398            8 :         let Some(reg) = st
     399            8 :             .store
     400            8 :             .get(tenant, Kind::Registration, &reg_id)
     401            8 :             .await
     402            8 :             .ok()
     403            8 :             .flatten()
     404              :         else {
     405            0 :             continue;
     406              :         };
     407              :         // 5.11.2.4 noLongerMatching: the copy at a registration the updated
     408              :         // Subscription no longer matches is deleted there and unmapped
     409            8 :         if let (Some(c), Some(ctx)) = (&csr, &csr_ctx) {
     410            8 :             if !crate::registry::csr_matches_subscription(c, &reg, ctx) {
     411            4 :                 let (st2, t2, own) = (st.clone(), tenant.clone(), own_id.to_owned());
     412            4 :                 crate::spawn(async move {
     413            4 :                     on_csource_notification(&st2, &t2, &own, Some("noLongerMatching"), &[reg])
     414            4 :                         .await;
     415            4 :                 });
     416            4 :                 continue;
     417            4 :             }
     418            0 :         }
     419            4 :         let (st2, t2, sub2) = (st.clone(), tenant.clone(), sub.clone());
     420            4 :         let ctx_url = sub_ctx_url(st, &sub);
     421            4 :         let own = own_id.to_owned();
     422            4 :         crate::spawn(async move {
     423            4 :             let ctx = crate::notify::sub_context(&st2, &t2, &sub2).await;
     424            4 :             let Some(mut copy) = reduced_copy(&st2, &sub2, &reg, &remote_id, &ctx) else {
     425              :                 // nothing this registration covers is watched any more:
     426              :                 // the copy there has nothing left to notify. Decided before
     427              :                 // the updateSubscription gate: a source without it still
     428              :                 // has its copy deleted (and unmapped) here.
     429            0 :                 on_csource_notification(&st2, &t2, &own, Some("noLongerMatching"), &[reg]).await;
     430            0 :                 return;
     431              :             };
     432            4 :             if !crate::federation::doc_supports(&reg, "updateSubscription") {
     433            0 :                 return;
     434            4 :             }
     435            4 :             copy.as_object_mut().map(|o| o.remove("id"));
     436            4 :             forward_sub(
     437            4 :                 &st2,
     438            4 :                 &t2,
     439            4 :                 reqwest::Method::PATCH,
     440            4 :                 format!("{endpoint}/ngsi-ld/v1/subscriptions/{remote_id}"),
     441            4 :                 &reg_id,
     442            4 :                 &reg,
     443            4 :                 &ctx_url,
     444            4 :                 &sub_via_headers(&sub2),
     445            4 :                 Some(copy),
     446            4 :             )
     447            4 :             .await;
     448            4 :         });
     449              :     }
     450           92 : }
     451              : 
     452              : /// 5.8.5.4: delete the internal CSR subscription (5.11.6) and forward the
     453              : /// delete to every mapped remote supporting deleteSubscription.
     454          582 : pub(crate) async fn on_subscription_deleted(st: &AppState, tenant: &TenantId, own_id: &str) {
     455          582 :     let doc = ds_get(st, tenant, own_id).await;
     456          582 :     let csr_id = doc
     457          582 :         .get("csr_sub")
     458          582 :         .and_then(Value::as_str)
     459          582 :         .map(str::to_owned);
     460          582 :     let remotes = ds_remotes(&doc);
     461          582 :     for (_, (_, remote_id)) in &remotes {
     462           28 :         inbound_delete(st, remote_id).await;
     463              :     }
     464          582 :     let _ = st.store.delete(tenant, Kind::DistSub, own_id).await;
     465          582 :     if let Some(csr_id) = csr_id {
     466          574 :         let _ = st.store.delete(tenant, Kind::DistSub, &csr_id).await;
     467            8 :     }
     468          582 :     for (reg_id, (endpoint, remote_id)) in remotes {
     469           28 :         let stored = st
     470           28 :             .store
     471           28 :             .get(tenant, Kind::Registration, &reg_id)
     472           28 :             .await
     473           28 :             .ok()
     474           28 :             .flatten();
     475           28 :         if stored
     476           28 :             .as_ref()
     477           28 :             .is_some_and(|reg| !crate::federation::doc_supports(reg, "deleteSubscription"))
     478              :         {
     479            0 :             continue;
     480           28 :         }
     481           28 :         let (st2, t2) = (st.clone(), tenant.clone());
     482           28 :         let ctx_url = sub_ctx_url(st, &Value::Null);
     483           28 :         crate::spawn(async move {
     484           28 :             let reg = forward_reg(stored, &reg_id, &endpoint);
     485              :             // the Subscription is already deleted, so its stored chain is
     486              :             // gone — a delete-forward cannot create a copy, so a fresh
     487              :             // one-hop chain is loop-safe
     488           28 :             forward_sub(
     489           28 :                 &st2,
     490           28 :                 &t2,
     491           28 :                 reqwest::Method::DELETE,
     492           28 :                 format!("{endpoint}/ngsi-ld/v1/subscriptions/{remote_id}"),
     493           28 :                 &reg_id,
     494           28 :                 &reg,
     495           28 :                 &ctx_url,
     496           28 :                 &HeaderMap::new(),
     497           28 :                 None,
     498           28 :             )
     499           28 :             .await;
     500           24 :         });
     501              :     }
     502          582 : }
     503              : 
     504              : /// 4.14 / 5.8.5.4: the document a forwarded delete travels with. The stored
     505              : /// Context Source Registration carries the tenant, the contextSourceInfo and
     506              : /// the timeout/cooldown the forward needs; the id/endpoint pair is the
     507              : /// fallback for a registration that is already gone.
     508           36 : fn forward_reg(stored: Option<Value>, reg_id: &str, endpoint: &str) -> Value {
     509           36 :     stored.unwrap_or_else(|| json!({"id": reg_id, "endpoint": endpoint}))
     510           36 : }
     511              : 
     512              : /// 5.8.1.4: "The @context to be used for sending Notifications related to
     513              : /// this Subscription shall be the one specified in the jsonldContext field."
     514              : /// The forwarded copy carries the subscriber's own terms, so it is shipped
     515              : /// under the Subscription's @context, falling back to the core context.
     516          160 : fn sub_ctx_url(st: &AppState, sub: &Value) -> String {
     517          160 :     let source = sub
     518          160 :         .get("jsonldContext")
     519          160 :         .or_else(|| sub.get("__context"))
     520          160 :         .filter(|v| !v.is_null())
     521          160 :         .cloned()
     522          160 :         .unwrap_or_else(|| st.loader.core().source.clone());
     523          160 :     crate::federation::ctx_link_url(&HeaderMap::new(), &source)
     524          160 : }
     525              : 
     526              : /// The 5.8.1.4 localOnly=false block: one CSource notification for the
     527              : /// internal CSR subscription, dispatched per registration and triggerReason.
     528          116 : pub(crate) async fn on_csource_notification(
     529          116 :     st: &AppState,
     530          116 :     tenant: &TenantId,
     531          116 :     own_id: &str,
     532          116 :     reason: Option<&str>,
     533          116 :     regs: &[Value],
     534          116 : ) {
     535          116 :     let Some(sub) = st
     536          116 :         .store
     537          116 :         .get(tenant, Kind::Subscription, own_id)
     538          116 :         .await
     539          116 :         .ok()
     540          116 :         .flatten()
     541              :     else {
     542            0 :         return;
     543              :     };
     544              :     // 5.8.1.4: "If localOnly=false, each time a Context Source Notification
     545              :     // … is received" — a Subscription that has since been flipped to
     546              :     // localOnly creates no further remote copy.
     547          116 :     if !distributed(&sub) {
     548            4 :         return;
     549          112 :     }
     550          112 :     let ctx = crate::notify::sub_context(st, tenant, &sub).await;
     551          112 :     let ctx_url = sub_ctx_url(st, &sub);
     552          112 :     let via = sub_via_headers(&sub);
     553          112 :     let seen = crate::federation::via_tokens(&via);
     554          112 :     let reason = reason.unwrap_or("updated");
     555          120 :     for reg in regs {
     556          120 :         let Some(reg_id) = reg.get("id").and_then(Value::as_str) else {
     557            0 :             continue;
     558              :         };
     559              :         // auxiliary registrations take no part (5.8.1.4 lists exclusive,
     560              :         // redirect and inclusive only)
     561          120 :         if reg.get("mode").and_then(Value::as_str) == Some("auxiliary") {
     562            0 :             continue;
     563          120 :         }
     564              :         // Table 6.3.18-2: the Via listing "is used when determining matching
     565              :         // registrations" — a Context Source the Subscription already
     566              :         // travelled through must not receive a copy of it back.
     567          120 :         if reg
     568          120 :             .get("contextSourceAlias")
     569          120 :             .and_then(Value::as_str)
     570          120 :             .is_some_and(|a| seen.iter().any(|t| t == a))
     571              :         {
     572            4 :             continue;
     573          116 :         }
     574          116 :         let Some(endpoint) = reg
     575          116 :             .get("endpoint")
     576          116 :             .and_then(Value::as_str)
     577          116 :             .map(crate::federation::endpoint_base)
     578              :         else {
     579            0 :             continue;
     580              :         };
     581          116 :         let mapped = ds_remotes(&ds_get(st, tenant, own_id).await)
     582          116 :             .into_iter()
     583          116 :             .find(|(r, _)| r == reg_id)
     584          116 :             .map(|(_, v)| v);
     585          116 :         match (reason, mapped) {
     586              :             // an unmapped registration that starts (or keeps) matching gets
     587              :             // the reduced copy — newlyMatching, or "updated" reported by an
     588              :             // initial notification when no mapping exists yet
     589          100 :             ("newlyMatching" | "updated", None)
     590            0 :                 if crate::federation::doc_supports(reg, "createSubscription") =>
     591              :             {
     592           96 :                 let remote_id =
     593           96 :                     format!("urn:ngsi-ld:Subscription:distsub:{}", uuid::Uuid::new_v4());
     594           96 :                 let Some(copy) = reduced_copy(st, &sub, reg, &remote_id, &ctx) else {
     595              :                     // nothing this registration covers is watched — there is
     596              :                     // no reduced copy to forward to it
     597            0 :                     continue;
     598              :                 };
     599              :                 // 5.8.1.4/5.8.5.4: the mapping is stored BEFORE the forward —
     600              :                 // the remote id is broker-generated, and a delete Subscription
     601              :                 // arriving while the create-forward's response is still in
     602              :                 // flight must find the mapping or the delete-forward is lost
     603              :                 // (the ETSI 5814_01_01 pg race). A failed forward rolls the
     604              :                 // mapping back below.
     605           96 :                 inbound_put(st, &remote_id, tenant, own_id).await;
     606           96 :                 if !ds_set_remote(
     607           96 :                     st,
     608           96 :                     tenant,
     609           96 :                     own_id,
     610           96 :                     reg_id,
     611           96 :                     Some(json!([endpoint.clone(), remote_id])),
     612           96 :                 )
     613           96 :                 .await
     614              :                 {
     615              :                     // the Subscription was deleted while this notification
     616              :                     // was in flight — no remote copy is created for it
     617            0 :                     inbound_delete(st, &remote_id).await;
     618            0 :                     continue;
     619           96 :                 }
     620           96 :                 let (status, _) = forward_sub(
     621           96 :                     st,
     622           96 :                     tenant,
     623           96 :                     reqwest::Method::POST,
     624           96 :                     format!("{endpoint}/ngsi-ld/v1/subscriptions"),
     625           96 :                     reg_id,
     626           96 :                     reg,
     627           96 :                     &ctx_url,
     628           96 :                     &via,
     629           96 :                     Some(copy),
     630              :                 )
     631           96 :                 .await;
     632           92 :                 if !(200..300).contains(&status) {
     633            0 :                     inbound_delete(st, &remote_id).await;
     634            0 :                     ds_set_remote(st, tenant, own_id, reg_id, None).await;
     635           92 :                 }
     636              :             }
     637           16 :             ("updated", Some((_, remote_id)))
     638            0 :                 if crate::federation::doc_supports(reg, "updateSubscription") =>
     639              :             {
     640            0 :                 let Some(mut copy) = reduced_copy(st, &sub, reg, &remote_id, &ctx) else {
     641            0 :                     continue;
     642              :                 };
     643            0 :                 copy.as_object_mut().map(|o| o.remove("id"));
     644            0 :                 forward_sub(
     645            0 :                     st,
     646            0 :                     tenant,
     647            0 :                     reqwest::Method::PATCH,
     648            0 :                     format!("{endpoint}/ngsi-ld/v1/subscriptions/{remote_id}"),
     649            0 :                     reg_id,
     650            0 :                     reg,
     651            0 :                     &ctx_url,
     652            0 :                     &via,
     653            0 :                     Some(copy),
     654            0 :                 )
     655            0 :                 .await;
     656              :             }
     657           16 :             ("noLongerMatching", Some((_, remote_id))) => {
     658              :                 // The copy no longer serves this Subscription, whatever the
     659              :                 // source supports: unmapped first, so a notification it
     660              :                 // still sends is not forwarded to the subscriber while the
     661              :                 // delete is in flight, or forever where the source cannot
     662              :                 // delete it.
     663           12 :                 ds_set_remote(st, tenant, own_id, reg_id, None).await;
     664           12 :                 inbound_delete(st, &remote_id).await;
     665              :                 // 5.8.1.4: the delete is forwarded where the registration
     666              :                 // "indicates support for the delete Subscription operation"
     667           12 :                 if crate::federation::doc_supports(reg, "deleteSubscription") {
     668            8 :                     forward_sub(
     669            8 :                         st,
     670            8 :                         tenant,
     671            8 :                         reqwest::Method::DELETE,
     672            8 :                         format!("{endpoint}/ngsi-ld/v1/subscriptions/{remote_id}"),
     673            8 :                         reg_id,
     674            8 :                         reg,
     675            8 :                         &ctx_url,
     676            8 :                         &via,
     677            8 :                         None,
     678            8 :                     )
     679            8 :                     .await;
     680            4 :                 }
     681              :             }
     682            8 :             _ => {}
     683              :         }
     684              :     }
     685          112 : }
     686              : 
     687              : /// 5.8.1.4: "a copy of the original Subscription shall be reduced to what
     688              : /// is matched by the registration information"; with splitEntities the
     689              : /// q/geoQ/scopeQ members are removed; the notification attributes/pick/omit
     690              : /// members are removed; the endpoint is set to the local broker.
     691              : ///
     692              : /// 5.5.7 Term to URI expansion: the Subscription comes out of the store with
     693              : /// its names and types EXPANDED, while a registration delivered by a Context
     694              : /// Source Notification carries them COMPACTED. Every registration-derived
     695              : /// name is expanded against `ctx` before it is compared or inserted, so the
     696              : /// reduction happens in one representation — `expand_key` is idempotent on
     697              : /// an absolute IRI, which is what the stored registration already holds.
     698          120 : fn reduced_copy(
     699          120 :     st: &AppState,
     700          120 :     sub: &Value,
     701          120 :     reg: &Value,
     702          120 :     remote_id: &str,
     703          120 :     ctx: &antares_jsonld::Context,
     704          120 : ) -> Option<Value> {
     705          120 :     let mut copy = sub.clone();
     706          120 :     let Some(o) = copy.as_object_mut() else {
     707            0 :         return Some(copy);
     708              :     };
     709              :     // __via travels as the Via HTTP header the forward extends (6.3.17),
     710              :     // never as a body member
     711          960 :     for k in [
     712          120 :         "status",
     713          120 :         "timesSent",
     714          120 :         "lastNotification",
     715          120 :         "lastSuccess",
     716          120 :         "lastFailure",
     717          120 :         "createdAt",
     718          120 :         "modifiedAt",
     719          120 :         "localOnly",
     720          960 :     ] {
     721          960 :         o.remove(k);
     722          960 :     }
     723              :     // every broker-internal member, not a list of the ones known today:
     724              :     // __context, __via and the ADR-0020 __subject are this broker's own
     725              :     // record and none of them may cross to a Context Source
     726          858 :     o.retain(|k, _| !k.starts_with("__"));
     727          120 :     o.insert("id".into(), Value::String(remote_id.to_owned()));
     728              :     // reduce the entity selectors to the registration information
     729          120 :     let reg_entities: Vec<Value> = reg
     730          120 :         .get("information")
     731          120 :         .and_then(Value::as_array)
     732          120 :         .into_iter()
     733          120 :         .flatten()
     734          120 :         .filter_map(|i| i.get("entities").and_then(Value::as_array))
     735          120 :         .flatten()
     736          120 :         .map(|e| {
     737          120 :             let mut e = e.clone();
     738              :             // notification presentation arrayifies type — EntitySelector
     739              :             // wants the plain form back
     740          120 :             if let Some(o) = e.as_object_mut() {
     741          120 :                 if let Some(t) = o
     742          120 :                     .get("type")
     743          120 :                     .and_then(Value::as_array)
     744          120 :                     .and_then(|a| a.first())
     745          120 :                     .cloned()
     746          116 :                 {
     747          116 :                     o.insert("type".into(), t);
     748          116 :                 }
     749          120 :                 if let Some(t) = o.get("type").and_then(Value::as_str) {
     750          120 :                     let t = Value::String(ctx.expand_key(t));
     751          120 :                     o.insert("type".into(), t);
     752          120 :                 }
     753            0 :             }
     754          120 :             e
     755          120 :         })
     756          120 :         .collect();
     757          120 :     if !reg_entities.is_empty() {
     758          120 :         o.insert("entities".into(), Value::Array(reg_entities));
     759          120 :     }
     760              :     // watchedAttributes ∩ the registration's attribute scope
     761          120 :     let reg_attrs: Vec<String> = reg
     762          120 :         .get("information")
     763          120 :         .and_then(Value::as_array)
     764          120 :         .into_iter()
     765          120 :         .flatten()
     766          120 :         .flat_map(|i| {
     767          120 :             antares_model::registration_attribute_names(i)
     768          120 :                 .unwrap_or_default()
     769          120 :                 .into_iter()
     770          120 :                 .map(|n| ctx.expand_key(n))
     771          120 :         })
     772          120 :         .collect();
     773              :     // a periodic Subscription watches nothing (5.2.12 forbids the pair):
     774              :     // its copy gets no watchedAttributes either, or the source refuses it
     775          120 :     let periodic = o.contains_key("timeInterval");
     776          120 :     if !reg_attrs.is_empty() && !periodic {
     777           16 :         let watch: Vec<Value> = match o.get("watchedAttributes").and_then(Value::as_array) {
     778           12 :             Some(w) => w
     779           12 :                 .iter()
     780           24 :                 .filter(|a| a.as_str().is_some_and(|a| reg_attrs.iter().any(|r| r == a)))
     781           12 :                 .cloned()
     782           12 :                 .collect(),
     783              :             // 5.8.1.4 "reduced to what is matched by the registration
     784              :             // information": a watch-everything Subscription still only
     785              :             // watches the registered names at the source — otherwise an
     786              :             // unregistered attribute change notifies through the chain.
     787            4 :             None => reg_attrs.iter().map(|a| Value::String(a.clone())).collect(),
     788              :         };
     789              :         // Nothing the subscriber watches is matched by this registration
     790              :         // information: there is no reduced copy to forward — and an empty
     791              :         // watchedAttributes is a payload 5.2.12 forbids.
     792           16 :         if watch.is_empty() {
     793            0 :             return None;
     794           16 :         }
     795           16 :         o.insert("watchedAttributes".into(), Value::Array(watch));
     796          104 :     }
     797              :     // 5.8.1.4: with splitEntities the remote sees only fragments — the
     798              :     // q/geoQ/scopeQ conditions are evaluated LOCALLY after the 5.8.6 merge
     799              :     // (splitEntities is a Subscription member, 5.2.12)
     800          120 :     if sub.get("splitEntities").and_then(Value::as_bool) == Some(true) {
     801           60 :         for k in ["q", "geoQ", "scopeQ"] {
     802           60 :             o.remove(k);
     803           60 :         }
     804          100 :     }
     805          120 :     if let Some(n) = o.get_mut("notification").and_then(Value::as_object_mut) {
     806          360 :         for k in ["attributes", "pick", "omit"] {
     807          360 :             n.remove(k);
     808          360 :         }
     809              :         // Table 5.2.14.2-1: the delivery bookkeeping is this broker's own
     810              :         // record of notifying ITS subscriber. It lives inside `notification`,
     811              :         // it is output-only on a create ("implementations shall ignore
     812              :         // them"), and it says nothing the Context Source may act on — so it
     813              :         // stays here, like the top-level members stripped above.
     814          720 :         for k in [
     815          120 :             "status",
     816          120 :             "timesSent",
     817          120 :             "timesFailed",
     818          120 :             "lastNotification",
     819          120 :             "lastSuccess",
     820          120 :             "lastFailure",
     821          720 :         ] {
     822          720 :             n.remove(k);
     823          720 :         }
     824          120 :         n.insert(
     825          120 :             "endpoint".into(),
     826          120 :             json!({"uri": format!("{}/ex/v1/remote-notify", st.public_url)}),
     827              :         );
     828            0 :     }
     829          120 :     Some(copy)
     830          120 : }
     831              : 
     832              : /// One forwarded subscription operation, through the shared federation
     833              : /// forward (egress policy, Via, contextSourceInfo, tenant mapping).
     834              : /// `via` is the chain the Subscription arrived with ([`sub_via_headers`]);
     835              : /// the forward appends this broker's alias to it (6.3.17), so downstream
     836              : /// brokers see the full path and can cut a loop.
     837              : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
     838          136 : async fn forward_sub(
     839          136 :     st: &AppState,
     840          136 :     tenant: &TenantId,
     841          136 :     method: reqwest::Method,
     842          136 :     url: String,
     843          136 :     reg_id: &str,
     844          136 :     reg: &Value,
     845          136 :     ctx_url: &str,
     846          136 :     via: &HeaderMap,
     847          136 :     body: Option<Value>,
     848          136 : ) -> (u16, Value) {
     849          136 :     let fed = crate::federation::fed_reg_of(reg_id, reg);
     850          128 :     let (status, body, _) =
     851          136 :         crate::federation::forward(st, method, url, &[], via, tenant, &fed, ctx_url, body).await;
     852          128 :     (status, body)
     853          128 : }
     854              : 
     855              : /// 5.8.6 splitEntities=true inbound merge: each notified Entity "shall be
     856              : /// retrieved locally and from all Context Sources that have information
     857              : /// about these Entities, except for the one from which the Notification has
     858              : /// been received", merged with the notified fragment, and "all Entities
     859              : /// that do not match the query, geoquery and Scope query conditions of the
     860              : /// local Subscription shall be removed".
     861           20 : async fn split_merge(
     862           20 :     st: &AppState,
     863           20 :     tenant: &TenantId,
     864           20 :     sub: &Value,
     865           20 :     origin_reg: Option<&str>,
     866           20 :     data: Vec<Value>,
     867           20 : ) -> Result<Vec<Value>, NgsiError> {
     868           20 :     let ctx = crate::notify::sub_context(st, tenant, sub).await;
     869           20 :     let headers = HeaderMap::new();
     870           20 :     let mut out = Vec::new();
     871           24 :     for ent in data {
     872           24 :         let Some(obj) = ent.as_object() else { continue };
     873              :         // inbound notification presentation → the expanded storage form.
     874              :         // 5.5.4 bans "urn:ngsi-ld:null" as a first level member value "with
     875              :         // the exception of NGSI-LD Fragments … or to represent deleted
     876              :         // Properties in concise representation as part of notifications", so
     877              :         // a notified deletion (4.5.7) is a valid payload here and a
     878              :         // validating expansion would drop the whole Entity. `sys` keeps the
     879              :         // timestamps a sysAttrs subscriber asked for; this is a translation
     880              :         // of a document the Context Source already produced, not an
     881              :         // admission check on client input.
     882           24 :         let Ok(mut merged) = antares_jsonld::expand_entity(
     883           24 :             obj,
     884           24 :             &ctx,
     885           24 :             antares_jsonld::ExpandOpts {
     886           24 :                 allow_null: true,
     887           24 :                 sys: true,
     888           24 :                 ..Default::default()
     889           24 :             },
     890           24 :         ) else {
     891            0 :             continue;
     892              :         };
     893           24 :         let Some(id) = merged.get("id").and_then(Value::as_str).map(str::to_owned) else {
     894            0 :             continue;
     895              :         };
     896           24 :         if let Ok(Some(local)) = st.store.get(tenant, Kind::Entity, &id).await {
     897            8 :             crate::federation::merge_docs(&mut merged, &local, false);
     898           16 :         }
     899           24 :         let mut warnings = Vec::new();
     900           24 :         let fed = crate::federation::fed_retrieve(
     901           24 :             st,
     902           24 :             tenant,
     903           24 :             &headers,
     904           24 :             &ctx,
     905           24 :             &id,
     906           24 :             None,
     907           24 :             origin_reg,
     908           24 :             &mut warnings,
     909           24 :             &mut None,
     910           24 :         )
     911           24 :         .await?;
     912           48 :         for aux_pass in [false, true] {
     913           48 :             for (aux, doc) in &fed {
     914           16 :                 if *aux == aux_pass {
     915            8 :                     crate::federation::merge_docs(&mut merged, doc, *aux);
     916            8 :                 }
     917              :             }
     918              :         }
     919           24 :         if crate::notify::linked_eval(st, tenant, None, |l| {
     920           24 :             crate::notify::conditions_match(sub, &merged, &ctx, l)
     921           24 :         })
     922           24 :         .await
     923           16 :         {
     924           16 :             // 5.3.1/5.8.6: notification data carries Entities in their
     925           16 :             // API representation — shape and compact the merged storage
     926           16 :             // form exactly like the local notify path.
     927           16 :             let shape = crate::notify::notif_shape(sub, &ctx);
     928           16 :             let shaped = crate::repr::apply(&merged, &shape.repr);
     929           16 :             out.push(crate::repr::compact_for(&shape.repr, &shaped, &ctx));
     930           16 :         }
     931              :     }
     932           20 :     Ok(out)
     933           20 : }
     934              : 
     935              : /// POST /ex/v1/remote-notify — the local broker's endpoint for
     936              : /// notifications from forwarded subscription copies. 5.8.1.4: "the mapping
     937              : /// of the received subscriptionId with the own Subscription identifier …
     938              : /// to enable forwarding received notifications to the original subscriber."
     939           86 : pub async fn remote_notify(State(st): State<AppState>, body: Bytes) -> Response {
     940           86 :     match remote_notify_inner(&st, &body).await {
     941           60 :         Ok(r) => r,
     942           26 :         Err(e) => e.into_response(),
     943              :     }
     944           86 : }
     945              : 
     946           86 : async fn remote_notify_inner(st: &AppState, body: &[u8]) -> ApiResult<Response> {
     947           86 :     let v: Value = serde_json::from_slice(body)
     948           86 :         .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
     949              :     // Peer-facing entry point: the Entity count is capped before any store
     950              :     // touch or per-Entity work, under the same ceiling a client batch gets
     951              :     // (ANTARES_MAX_BATCH_ITEMS). One notification drives one local retrieve
     952              :     // and one federated fan-out per Entity in the 5.8.6 merge, so an
     953              :     // uncapped data array is an amplification lever.
     954           82 :     let cap = *crate::bounds::MAX_BATCH_ITEMS;
     955           82 :     if v.get("data")
     956           82 :         .and_then(Value::as_array)
     957           82 :         .is_some_and(|a| a.len() > cap)
     958              :     {
     959            4 :         return Err(NgsiError::BadRequestData(format!(
     960            4 :             "notification data carries more than {cap} Entities"
     961            4 :         ))
     962            4 :         .into());
     963           78 :     }
     964           78 :     let sid = v
     965           78 :         .get("subscriptionId")
     966           78 :         .and_then(Value::as_str)
     967           78 :         .ok_or_else(|| NgsiError::BadRequestData("notification without subscriptionId".into()))?;
     968           78 :     let Some((tenant, own_id)) = inbound_get(st, sid).await else {
     969           14 :         return Err(ApiError::from(NgsiError::ResourceNotFound(format!(
     970           14 :             "no distributed subscription maps {sid}"
     971           14 :         ))));
     972              :     };
     973              :     // read back from the broker's own inbound index, not from the request
     974           64 :     let tenant = TenantId::new_internal(&tenant)
     975           64 :         .map_err(|_| NgsiError::InternalError("stored tenant invalid".into()))?;
     976           64 :     let Some(sub) = st
     977           64 :         .store
     978           64 :         .get(&tenant, Kind::Subscription, &own_id)
     979           64 :         .await
     980           64 :         .ok()
     981           64 :         .flatten()
     982              :     else {
     983              :         // the subscriber is gone: prune the mapping on touch so a remote
     984              :         // that keeps notifying cannot pin a dead index entry forever, and
     985              :         // answer about the peer's own id — the local Subscription id is not
     986              :         // the peer's to learn
     987            4 :         inbound_delete(st, sid).await;
     988            4 :         return Err(ApiError::from(NgsiError::ResourceNotFound(format!(
     989            4 :             "no distributed subscription maps {sid}"
     990            4 :         ))));
     991              :     };
     992              :     // the origin of this notification is the registration its remote
     993              :     // subscription was created at
     994           60 :     let origin_reg_id = ds_remotes(&ds_get(st, &tenant, &own_id).await)
     995           60 :         .into_iter()
     996           60 :         .find(|(_, (_, rid))| rid.as_str() == sid)
     997           60 :         .map(|(reg_id, _)| reg_id);
     998              :     // 5.8.6: "if a Context Source filter is defined, then only the
     999              :     // subscribed Entities whose origin Context Source matches the referred
    1000              :     // filter shall be included".
    1001           60 :     if let Some(csf) = sub.get("csf").and_then(Value::as_str) {
    1002            8 :         if let Ok(ast) = antares_ql::parse_q(csf) {
    1003            8 :             let origin_reg = match origin_reg_id.as_ref() {
    1004            8 :                 Some(reg_id) => st
    1005            8 :                     .store
    1006            8 :                     .get(&tenant, Kind::Registration, reg_id)
    1007            8 :                     .await
    1008            8 :                     .ok()
    1009            8 :                     .flatten(),
    1010            0 :                 None => None,
    1011              :             };
    1012              :             // 5.8.1.4: the csf is written in the Subscription's own @context,
    1013              :             // and 5.11.2.4 already read it in that @context when it chose the
    1014              :             // sources this Subscription was forwarded to. Reading it in the
    1015              :             // core context here would let the two disagree, and a source the
    1016              :             // broker deliberately subscribed to would have every notification
    1017              :             // dropped.
    1018            8 :             let ctx = crate::notify::sub_context(st, &tenant, &sub).await;
    1019            8 :             let matches =
    1020            8 :                 origin_reg.is_some_and(|reg| crate::registry::csf_matches(&ast, &reg, &ctx));
    1021            8 :             if !matches {
    1022              :                 // origin gated out — acknowledged, nothing forwarded
    1023            0 :                 return Ok(StatusCode::OK.into_response());
    1024            8 :             }
    1025            0 :         }
    1026           52 :     }
    1027           60 :     let mut data: Vec<Value> = v
    1028           60 :         .get("data")
    1029           60 :         .and_then(Value::as_array)
    1030           60 :         .cloned()
    1031           60 :         .unwrap_or_default();
    1032              :     // 5.2.33 / 5.8.1.4: the remote copy carries the REGISTRATION's entity
    1033              :     // scope, which may be broader than the original Subscription's own
    1034              :     // entities selector — re-filter inbound entities against the original
    1035              :     // selector (id over idPattern precedence included) before forwarding.
    1036           60 :     let sel_ctx = crate::notify::sub_context(st, &tenant, &sub).await;
    1037           68 :     data.retain(|e| {
    1038           68 :         let id = e.get("id").and_then(Value::as_str).unwrap_or("");
    1039           68 :         let types: Vec<Value> = match e.get("type") {
    1040           68 :             Some(Value::String(t)) => vec![Value::String(sel_ctx.expand_key(t))],
    1041            0 :             Some(Value::Array(a)) => a
    1042            0 :                 .iter()
    1043            0 :                 .filter_map(Value::as_str)
    1044            0 :                 .map(|t| Value::String(sel_ctx.expand_key(t)))
    1045            0 :                 .collect(),
    1046            0 :             _ => Vec::new(),
    1047              :         };
    1048           68 :         let shim = serde_json::json!({"id": id, "type": types});
    1049           68 :         crate::notify::selector_match(&sub, &shim, &sel_ctx)
    1050           68 :     });
    1051           60 :     if data.is_empty() {
    1052              :         // acknowledged; nothing the original Subscription selected
    1053            4 :         return Ok(StatusCode::OK.into_response());
    1054           56 :     }
    1055              :     // 5.8.6 splitEntities=true: the notified Entities are fragments —
    1056              :     // retrieve them locally and from all other Context Sources (except the
    1057              :     // origin), merge, and re-filter by the local Subscription's conditions.
    1058           56 :     if sub.get("splitEntities").and_then(Value::as_bool) == Some(true) {
    1059           20 :         data = split_merge(st, &tenant, &sub, origin_reg_id.as_deref(), data).await?;
    1060           20 :         if data.is_empty() {
    1061              :             // "If there are Entities in the data member of the Notification
    1062              :             // copy, the Notification copy shall be forwarded" — none left
    1063            4 :             return Ok(StatusCode::OK.into_response());
    1064           16 :         }
    1065           36 :     }
    1066              :     // 5.8.6: forward to the original subscriber under the OWN subscriptionId
    1067           52 :     let split = sub.get("splitEntities").and_then(Value::as_bool) == Some(true);
    1068           52 :     let (st2, sub2, t2) = (st.clone(), sub.clone(), tenant.clone());
    1069           52 :     crate::spawn(async move {
    1070           52 :         let ctx = crate::notify::sub_context(&st2, &t2, &sub2).await;
    1071              :         // split_merge shaped its Entities already
    1072           52 :         let data = if split {
    1073           16 :             data
    1074              :         } else {
    1075           36 :             project_forwarded(&sub2, data, &ctx)
    1076              :         };
    1077           52 :         crate::notify::deliver(&st2, &t2, &sub2, data, &ctx).await;
    1078           50 :     });
    1079           52 :     Ok(StatusCode::OK.into_response())
    1080           86 : }
    1081              : 
    1082              : /// The local Subscription's projection (attributes, pick, omit, format)
    1083              : /// over Entities a Context Source notified: the source answers
    1084              : /// its own copy's projection, which need not be the subscriber's. Each is
    1085              : /// read back into the storage form, shaped and compacted as the local
    1086              : /// notify path does; one that does not read is dropped.
    1087           40 : fn project_forwarded(sub: &Value, data: Vec<Value>, ctx: &antares_jsonld::Context) -> Vec<Value> {
    1088           40 :     let shape = crate::notify::notif_shape(sub, ctx);
    1089           40 :     data.iter()
    1090           40 :         .filter_map(Value::as_object)
    1091           40 :         .filter_map(|obj| {
    1092           40 :             antares_jsonld::expand_entity(
    1093           40 :                 obj,
    1094           40 :                 ctx,
    1095           40 :                 antares_jsonld::ExpandOpts {
    1096           40 :                     allow_null: true,
    1097           40 :                     sys: true,
    1098           40 :                     ..Default::default()
    1099           40 :                 },
    1100              :             )
    1101           40 :             .ok()
    1102           40 :         })
    1103           40 :         .map(|doc| {
    1104           40 :             let shaped = crate::repr::apply(&doc, &shape.repr);
    1105           40 :             crate::repr::compact_for(&shape.repr, &shaped, ctx)
    1106           40 :         })
    1107           40 :         .collect()
    1108           40 : }
    1109              : 
    1110              : #[cfg(test)]
    1111              : mod tests {
    1112              :     use super::*;
    1113              : 
    1114              :     /// a forwarded notification carries what the local
    1115              :     /// Subscription projects, not what the source sent.
    1116              :     #[test]
    1117            4 :     fn a_forwarded_notification_takes_the_local_projection() {
    1118            4 :         let ctx = antares_jsonld::core_context();
    1119            4 :         let sub = json!({"notification": {"attributes": [format!("{DC}speed")]}});
    1120            4 :         let data = vec![json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
    1121            4 :             "speed": {"type": "Property", "value": 1},
    1122            4 :             "brand": {"type": "Property", "value": "x"}})];
    1123            4 :         let out = project_forwarded(&sub, data, &ctx);
    1124            4 :         assert_eq!(out.len(), 1);
    1125            4 :         assert_eq!(out[0]["speed"]["value"], 1, "{}", out[0]);
    1126            4 :         assert!(out[0].get("brand").is_none(), "{}", out[0]);
    1127            4 :     }
    1128              : 
    1129              :     /// The default @context prefix every Term expands to (5.5.7).
    1130              :     const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
    1131              : 
    1132              :     /// A registration exactly as `on_csource_notification` receives it: out
    1133              :     /// of a Context Source Notification, whose names and types
    1134              :     /// `present_registration` has COMPACTED.
    1135           28 :     fn reg_doc() -> Value {
    1136           28 :         json!({
    1137           28 :             "id": "urn:ngsi-ld:ContextSourceRegistration:r1",
    1138           28 :             "endpoint": "http://source.example.org",
    1139           28 :             "operations": ["createSubscription", "updateSubscription", "deleteSubscription"],
    1140           28 :             "information": [{
    1141           28 :                 "entities": [{"type": ["Vehicle"], "id": "urn:ngsi-ld:Vehicle:1"}],
    1142           28 :                 "propertyNames": ["speed"],
    1143              :             }],
    1144              :         })
    1145           28 :     }
    1146              : 
    1147              :     /// A Subscription exactly as the store holds it: names and types
    1148              :     /// EXPANDED by `normalize_subscription` (5.5.7).
    1149           40 :     fn sub_doc() -> Value {
    1150           40 :         json!({
    1151           40 :             "id": "urn:ngsi-ld:Subscription:own",
    1152           40 :             "type": "Subscription",
    1153           40 :             "entities": [{"type": format!("{DC}Vehicle")}, {"type": format!("{DC}Device")}],
    1154           40 :             "watchedAttributes": [format!("{DC}speed"), format!("{DC}brand")],
    1155           40 :             "q": "speed>10",
    1156           40 :             "geoQ": {"georel": "near;maxDistance==1000"},
    1157           40 :             "scopeQ": "/A",
    1158           40 :             "localOnly": false,
    1159           40 :             "status": "active",
    1160           40 :             "timesSent": 7,
    1161           40 :             "createdAt": "2026-01-01T00:00:00Z",
    1162           40 :             "modifiedAt": "2026-01-02T00:00:00Z",
    1163           40 :             "__context": "http://example.org/ctx.jsonld",
    1164           40 :             "__via": "1.1 upstream-broker",
    1165              :             // Table 5.2.14.2-1 keeps the delivery bookkeeping HERE, not at
    1166              :             // the top level: this is the shape `record_delivery` leaves
    1167              :             // behind, and the shape a forward is built from.
    1168           40 :             "notification": {
    1169           40 :                 "attributes": [format!("{DC}speed")],
    1170           40 :                 "pick": ["speed"],
    1171           40 :                 "omit": ["brand"],
    1172           40 :                 "endpoint": {"uri": "http://subscriber.example.org/cb"},
    1173           40 :                 "status": "ok",
    1174           40 :                 "timesSent": 7,
    1175           40 :                 "timesFailed": 2,
    1176           40 :                 "lastNotification": "2026-01-03T00:00:00Z",
    1177           40 :                 "lastSuccess": "2026-01-03T00:00:00Z",
    1178           40 :                 "lastFailure": "2026-01-02T00:00:00Z",
    1179              :             },
    1180              :         })
    1181           40 :     }
    1182              : 
    1183              :     /// a periodic Subscription that watches everything gets no
    1184              :     /// watchedAttributes in its copy: 5.2.12 forbids the pair with
    1185              :     /// timeInterval, so the source refused every such copy.
    1186              :     #[test]
    1187            4 :     fn a_periodic_copy_watches_nothing() {
    1188            4 :         let st = AppState::new("antares-ds-periodic".into());
    1189            4 :         let mut sub = sub_doc();
    1190            4 :         if let Some(o) = sub.as_object_mut() {
    1191            4 :             o.remove("watchedAttributes");
    1192            4 :             o.insert("timeInterval".into(), json!(10));
    1193            4 :         }
    1194            4 :         let copy = reduced_copy(
    1195            4 :             &st,
    1196            4 :             &sub,
    1197            4 :             &reg_doc(),
    1198            4 :             "urn:ngsi-ld:Subscription:remote1",
    1199            4 :             &st.loader.core(),
    1200              :         )
    1201            4 :         .expect("a copy the registration covers");
    1202            4 :         assert_eq!(copy.get("watchedAttributes"), None, "{copy}");
    1203            4 :         assert_eq!(copy.get("timeInterval"), Some(&json!(10)));
    1204            4 :     }
    1205              : 
    1206              :     /// 5.8.1.4: "a copy of the original Subscription shall be reduced to
    1207              :     /// what is matched by the registration information … Also from the
    1208              :     /// notification member, the attributes, pick and omit members are to be
    1209              :     /// removed. The copied Subscription is then forwarded to the Context
    1210              :     /// Source as a new Subscription where the notification endpoint is set
    1211              :     /// to that of the local Broker." Nothing outside the registration's
    1212              :     /// scope, and no local bookkeeping, may travel with the copy.
    1213              :     #[test]
    1214            4 :     fn clause_5_8_1_reduced_copy_carries_only_the_registration_scope() {
    1215            4 :         let st = AppState::new("antares-ds-reduce".into());
    1216            4 :         let copy = reduced_copy(
    1217            4 :             &st,
    1218            4 :             &sub_doc(),
    1219            4 :             &reg_doc(),
    1220            4 :             "urn:ngsi-ld:Subscription:remote1",
    1221            4 :             &st.loader.core(),
    1222              :         )
    1223            4 :         .expect("a copy the registration covers");
    1224            4 :         assert_eq!(copy["id"], json!("urn:ngsi-ld:Subscription:remote1"));
    1225            4 :         assert_ne!(
    1226            4 :             copy["id"],
    1227            4 :             json!("urn:ngsi-ld:Subscription:own"),
    1228              :             "the remote must be told the broker-generated id, not the local one"
    1229              :         );
    1230            4 :         let ents = copy["entities"].as_array().expect("entities");
    1231            4 :         assert_eq!(ents.len(), 1, "{copy}");
    1232            4 :         assert_eq!(ents[0]["type"], json!(format!("{DC}Vehicle")));
    1233            4 :         assert!(
    1234            4 :             !copy.to_string().contains(&format!("{DC}Device")),
    1235              :             "a selector the registration does not cover must NOT be forwarded: {copy}"
    1236              :         );
    1237            4 :         assert_eq!(
    1238            4 :             copy["watchedAttributes"],
    1239            4 :             json!([format!("{DC}speed")]),
    1240              :             "watchedAttributes are intersected with the registered names"
    1241              :         );
    1242              :         // local-only bookkeeping never leaves the broker — the Via chain
    1243              :         // travels as the HTTP header the forward extends, never in the body
    1244           40 :         for k in [
    1245            4 :             "status",
    1246            4 :             "timesSent",
    1247            4 :             "lastNotification",
    1248            4 :             "lastSuccess",
    1249            4 :             "lastFailure",
    1250            4 :             "createdAt",
    1251            4 :             "modifiedAt",
    1252            4 :             "__context",
    1253            4 :             "__via",
    1254            4 :             "localOnly",
    1255            4 :         ] {
    1256           40 :             assert!(copy.get(k).is_none(), "{k} must not be forwarded: {copy}");
    1257              :         }
    1258              :         // Table 5.2.14.2-1: the same bookkeeping the store keeps under
    1259              :         // `notification` is this broker's alone — it says how often IT has
    1260              :         // notified ITS subscriber, is output-only on a create, and has no
    1261              :         // meaning to the Context Source receiving the copy.
    1262           24 :         for k in [
    1263            4 :             "status",
    1264            4 :             "timesSent",
    1265            4 :             "timesFailed",
    1266            4 :             "lastNotification",
    1267            4 :             "lastSuccess",
    1268            4 :             "lastFailure",
    1269            4 :         ] {
    1270           24 :             assert!(
    1271           24 :                 copy["notification"].get(k).is_none(),
    1272              :                 "notification.{k} must not be forwarded: {copy}"
    1273              :             );
    1274              :         }
    1275            4 :         let n = &copy["notification"];
    1276           12 :         for k in ["attributes", "pick", "omit"] {
    1277           12 :             assert!(
    1278           12 :                 n.get(k).is_none(),
    1279              :                 "notification.{k} must be removed: {copy}"
    1280              :             );
    1281              :         }
    1282            4 :         let uri = n["endpoint"]["uri"].as_str().expect("endpoint uri");
    1283            4 :         assert!(uri.ends_with("/ex/v1/remote-notify"), "{uri}");
    1284            4 :         assert_ne!(
    1285              :             uri, "http://subscriber.example.org/cb",
    1286              :             "the source must never learn the original subscriber's endpoint"
    1287              :         );
    1288              :         // splitEntities is absent here, so the filters stay on the copy
    1289            4 :         assert_eq!(copy["q"], json!("speed>10"));
    1290            4 :     }
    1291              : 
    1292              :     /// 5.8.1.4: "If the splitEntities member is explicitly set to true …
    1293              :     /// the members q, geoQ and scopeQ shall be removed from the created
    1294              :     /// copy"; and a Subscription with no watchedAttributes is still reduced
    1295              :     /// to the registered names, so an unregistered Attribute change cannot
    1296              :     /// notify through the chain.
    1297              :     #[test]
    1298            4 :     fn clause_5_8_1_reduced_copy_split_and_watch_everything() {
    1299            4 :         let st = AppState::new("antares-ds-split".into());
    1300            4 :         let mut sub = sub_doc();
    1301            4 :         sub["splitEntities"] = json!(true);
    1302            4 :         let copy = reduced_copy(
    1303            4 :             &st,
    1304            4 :             &sub,
    1305            4 :             &reg_doc(),
    1306            4 :             "urn:ngsi-ld:Subscription:remote2",
    1307            4 :             &st.loader.core(),
    1308              :         )
    1309            4 :         .expect("copy");
    1310           12 :         for k in ["q", "geoQ", "scopeQ"] {
    1311           12 :             assert!(
    1312           12 :                 copy.get(k).is_none(),
    1313              :                 "{k} is evaluated locally after the 5.8.6 merge: {copy}"
    1314              :             );
    1315              :         }
    1316            4 :         let mut watch_all = sub_doc();
    1317            4 :         watch_all
    1318            4 :             .as_object_mut()
    1319            4 :             .expect("object")
    1320            4 :             .remove("watchedAttributes");
    1321            4 :         let copy = reduced_copy(
    1322            4 :             &st,
    1323            4 :             &watch_all,
    1324            4 :             &reg_doc(),
    1325            4 :             "urn:ngsi-ld:Subscription:remote3",
    1326            4 :             &st.loader.core(),
    1327              :         )
    1328            4 :         .expect("copy");
    1329            4 :         assert_eq!(
    1330            4 :             copy["watchedAttributes"],
    1331            4 :             json!([format!("{DC}speed")]),
    1332              :             "a watch-everything Subscription still only watches the \
    1333              :              registered names at the source"
    1334              :         );
    1335            4 :     }
    1336              : 
    1337              :     /// 5.8.1.4 "reduced to what is matched by the registration information",
    1338              :     /// with 5.5.7 Term to URI expansion: the registration arrives through a
    1339              :     /// Context Source Notification (names COMPACTED), the Subscription comes
    1340              :     /// out of the store (names EXPANDED). The intersection must be taken in
    1341              :     /// ONE representation — an empty `watchedAttributes` is a payload 5.2.12
    1342              :     /// forbids, and it narrows the forwarded copy to nothing.
    1343              :     #[test]
    1344            4 :     fn clause_5_8_1_reduced_copy_intersects_across_representations() {
    1345            4 :         let st = AppState::new("antares-ds-expand".into());
    1346            4 :         let copy = reduced_copy(
    1347            4 :             &st,
    1348            4 :             &sub_doc(),
    1349            4 :             &reg_doc(),
    1350            4 :             "urn:ngsi-ld:Subscription:remote4",
    1351            4 :             &st.loader.core(),
    1352              :         )
    1353            4 :         .expect("a copy the registration covers");
    1354            4 :         assert_eq!(
    1355            4 :             copy["watchedAttributes"],
    1356            4 :             json!([format!("{DC}speed")]),
    1357              :             "the compacted registered name must be expanded before the \
    1358              :              intersection: {copy}"
    1359              :         );
    1360            4 :         assert_ne!(
    1361            4 :             copy["watchedAttributes"],
    1362            4 :             json!([]),
    1363              :             "5.2.12: watchedAttributes, when present, is a non-empty array"
    1364              :         );
    1365            4 :         assert!(
    1366            4 :             !copy.to_string().contains(&format!("{DC}brand")),
    1367              :             "an unregistered watched name must not be forwarded: {copy}"
    1368              :         );
    1369            4 :         assert_eq!(
    1370            4 :             copy["entities"],
    1371            4 :             json!([{"type": format!("{DC}Vehicle"), "id": "urn:ngsi-ld:Vehicle:1"}]),
    1372              :             "the registration's selector travels in the Subscription's own \
    1373              :              representation: {copy}"
    1374              :         );
    1375            4 :     }
    1376              : 
    1377              :     /// 5.8.1.4: "Based on the content of the Subscription, a Context Source
    1378              :     /// Registration Subscription shall be created (clause 5.11.2)" — 5.11.2.4
    1379              :     /// matches registrations on csf, geoQ, scopeQ, temporalQ and the
    1380              :     /// notification attributes as well, so a Registration Subscription built
    1381              :     /// from entities and watchedAttributes alone offers the Subscription to
    1382              :     /// sources the subscriber excluded.
    1383              :     #[tokio::test]
    1384            4 :     async fn clause_5_8_1_csr_subscription_carries_every_matching_member() {
    1385            4 :         let st = AppState::new("antares-ds-csr".into());
    1386            4 :         let t = TenantId::default();
    1387            4 :         let mut sub = sub_doc();
    1388            4 :         sub["csf"] = json!("name==\"SourceA\"");
    1389            4 :         sub["temporalQ"] = json!({"timerel": "before", "timeAt": "2026-01-01T00:00:00Z"});
    1390            4 :         let own = sub["id"].as_str().expect("id").to_owned();
    1391            4 :         st.store
    1392            4 :             .create(&t, Kind::Subscription, &own, sub.clone())
    1393            4 :             .await
    1394            4 :             .expect("create");
    1395            4 :         on_subscription_created(&st, &t, &sub).await;
    1396            4 :         let csr_id = ds_get(&st, &t, &own).await["csr_sub"]
    1397            4 :             .as_str()
    1398            4 :             .expect("csr_sub")
    1399            4 :             .to_owned();
    1400            4 :         let csr = st
    1401            4 :             .store
    1402            4 :             .get(&t, Kind::DistSub, &csr_id)
    1403            4 :             .await
    1404            4 :             .ok()
    1405            4 :             .flatten()
    1406            4 :             .expect("csr subscription");
    1407           24 :         for k in [
    1408            4 :             "entities",
    1409            4 :             "watchedAttributes",
    1410            4 :             "csf",
    1411            4 :             "geoQ",
    1412            4 :             "scopeQ",
    1413            4 :             "temporalQ",
    1414            4 :         ] {
    1415           24 :             assert!(
    1416           24 :                 csr.get(k).is_some(),
    1417              :                 "{k} decides which registrations match: {csr}"
    1418              :             );
    1419              :         }
    1420            4 :         assert_eq!(
    1421            4 :             csr["notification"]["attributes"],
    1422            4 :             json!([format!("{DC}speed")]),
    1423              :             "5.11.2.4 unions notification.attributes into the match spec: {csr}"
    1424              :         );
    1425            4 :         assert!(
    1426            4 :             csr.get("q").is_none(),
    1427            4 :             "q filters Entity Attributes, not registration properties: {csr}"
    1428            4 :         );
    1429            4 :     }
    1430              : 
    1431              :     /// 6.3.18: the Via header exists "to avoid infinite loops". A forwarded
    1432              :     /// Subscription copy (5.8.1.4) arrives with the Via chain of the brokers
    1433              :     /// it has already passed through; a chain that names THIS broker means
    1434              :     /// the copy has looped back, so the Subscription serves locally and the
    1435              :     /// distributed half is NOT created — otherwise two mutually registered
    1436              :     /// brokers re-forward copies of copies without bound.
    1437              :     #[tokio::test]
    1438            4 :     async fn clause_6_3_18_looping_via_chain_suppresses_the_distributed_half() {
    1439            4 :         let st = AppState::new("antares-ds-viahost".into());
    1440            4 :         let t = TenantId::default();
    1441              :         // the copy's chain already names this broker's own alias
    1442            4 :         let mut looped = sub_doc();
    1443            4 :         looped["__via"] = json!("1.1 sourceX, 1.1 antares-ds-viahost");
    1444            4 :         let own = looped["id"].as_str().expect("id").to_owned();
    1445            4 :         st.store
    1446            4 :             .create(&t, Kind::Subscription, &own, looped.clone())
    1447            4 :             .await
    1448            4 :             .expect("create");
    1449            4 :         on_subscription_created(&st, &t, &looped).await;
    1450            4 :         assert!(
    1451            4 :             ds_get(&st, &t, &own).await.get("csr_sub").is_none(),
    1452              :             "a looped copy must not create the internal Registration Subscription"
    1453              :         );
    1454              :         // positive control: a chain naming only OTHER brokers is not a loop
    1455            4 :         let mut chained = sub_doc();
    1456            4 :         chained["id"] = json!("urn:ngsi-ld:Subscription:chained");
    1457            4 :         chained["__via"] = json!("1.1 sourceX");
    1458            4 :         st.store
    1459            4 :             .create(
    1460            4 :                 &t,
    1461            4 :                 Kind::Subscription,
    1462            4 :                 "urn:ngsi-ld:Subscription:chained",
    1463            4 :                 chained.clone(),
    1464            4 :             )
    1465            4 :             .await
    1466            4 :             .expect("create");
    1467            4 :         on_subscription_created(&st, &t, &chained).await;
    1468            4 :         assert!(
    1469            4 :             ds_get(&st, &t, "urn:ngsi-ld:Subscription:chained")
    1470            4 :                 .await
    1471            4 :                 .get("csr_sub")
    1472            4 :                 .is_some(),
    1473            4 :             "a pass-through chain (A->B->C) must keep the distributed half"
    1474            4 :         );
    1475            4 :     }
    1476              : 
    1477              :     /// 5.8.1.4 gates the whole distributed block on "If localOnly=false": a
    1478              :     /// Subscription updated to localOnly=true forwards no further copy, and
    1479              :     /// the internal Context Source Registration Subscription plus the
    1480              :     /// mappings it already holds are torn down.
    1481              :     #[tokio::test]
    1482            4 :     async fn clause_5_8_1_local_only_flip_tears_the_distributed_half_down() {
    1483            4 :         let st = AppState::new("antares-ds-local".into());
    1484            4 :         let t = TenantId::default();
    1485            4 :         let mut sub = sub_doc();
    1486            4 :         sub["localOnly"] = json!(true);
    1487            4 :         let own = sub["id"].as_str().expect("id").to_owned();
    1488            4 :         let csr_id = "urn:ngsi-ld:CSourceSubscription:distsub:x";
    1489            4 :         st.store
    1490            4 :             .create(&t, Kind::Subscription, &own, sub.clone())
    1491            4 :             .await
    1492            4 :             .expect("create");
    1493            4 :         st.store
    1494            4 :             .create(&t, Kind::DistSub, csr_id, json!({"id": csr_id}))
    1495            4 :             .await
    1496            4 :             .expect("create");
    1497            4 :         ds_put(&st, &t, &own, json!({"csr_sub": csr_id})).await;
    1498              :         // a Context Source Notification arriving after the flip creates
    1499              :         // nothing at the source
    1500            4 :         on_csource_notification(&st, &t, &own, Some("newlyMatching"), &[reg_doc()]).await;
    1501            4 :         assert!(
    1502            4 :             ds_remotes(&ds_get(&st, &t, &own).await).is_empty(),
    1503              :             "a local-only Subscription forwards no copy"
    1504              :         );
    1505            4 :         on_subscription_updated(&st, &t, &own).await;
    1506            4 :         assert!(
    1507            4 :             st.store
    1508            4 :                 .get(&t, Kind::DistSub, csr_id)
    1509            4 :                 .await
    1510            4 :                 .ok()
    1511            4 :                 .flatten()
    1512            4 :                 .is_none(),
    1513              :             "the internal Registration Subscription must not survive the flip"
    1514              :         );
    1515            4 :         assert!(
    1516            4 :             st.store
    1517            4 :                 .get(&t, Kind::DistSub, &own)
    1518            4 :                 .await
    1519            4 :                 .ok()
    1520            4 :                 .flatten()
    1521            4 :                 .is_none(),
    1522            4 :             "the mapping document must not survive the flip"
    1523            4 :         );
    1524            4 :     }
    1525              : 
    1526              :     /// 5.8.1.4 noLongerMatching: a registration that cannot delete the
    1527              :     /// remote copy still loses its mapping, so a notification the orphaned
    1528              :     /// copy keeps sending reaches nobody.
    1529              :     #[tokio::test]
    1530            4 :     async fn clause_5_8_1_no_longer_matching_unmaps_a_source_without_delete() {
    1531            4 :         let st = AppState::new("antares-ds-nolonger".into());
    1532            4 :         let t = TenantId::default();
    1533            4 :         let sub = sub_doc();
    1534            4 :         let own = sub["id"].as_str().expect("id").to_owned();
    1535            4 :         st.store
    1536            4 :             .create(&t, Kind::Subscription, &own, sub)
    1537            4 :             .await
    1538            4 :             .expect("create");
    1539            4 :         ds_put(&st, &t, &own, json!({"csr_sub": "urn:csr:nolonger"})).await;
    1540            4 :         let mut reg = reg_doc();
    1541            4 :         reg["operations"] = json!(["createSubscription", "updateSubscription"]);
    1542            4 :         let reg_id = reg["id"].as_str().expect("id").to_owned();
    1543            4 :         assert!(
    1544            4 :             ds_set_remote(
    1545            4 :                 &st,
    1546            4 :                 &t,
    1547            4 :                 &own,
    1548            4 :                 &reg_id,
    1549            4 :                 Some(json!(["http://source.example.org", "urn:remote:nolonger"]))
    1550            4 :             )
    1551            4 :             .await
    1552              :         );
    1553            4 :         inbound_put(&st, "urn:remote:nolonger", &t, &own).await;
    1554            4 :         on_csource_notification(&st, &t, &own, Some("noLongerMatching"), &[reg]).await;
    1555            4 :         assert!(
    1556            4 :             ds_remotes(&ds_get(&st, &t, &own).await).is_empty(),
    1557              :             "the mapping outlived noLongerMatching"
    1558              :         );
    1559            4 :         assert!(
    1560            4 :             inbound_get(&st, "urn:remote:nolonger").await.is_none(),
    1561            4 :             "the inbound key outlived noLongerMatching"
    1562            4 :         );
    1563            4 :     }
    1564              : 
    1565              :     /// 5.8.1.4 stores ONE remote subscriptionId per (Subscription,
    1566              :     /// registration) pair. Two Context Source Notifications for the same pair
    1567              :     /// interleave at their forward, so the second insert is refused under the
    1568              :     /// store's own lock instead of overwriting — an overwritten mapping
    1569              :     /// orphans a live remote subscription at the source and doubles every
    1570              :     /// notification the subscriber receives.
    1571              :     #[tokio::test]
    1572            4 :     async fn clause_5_8_1_second_mapping_for_a_registration_is_refused() {
    1573            4 :         let st = AppState::new("antares-ds-cas".into());
    1574            4 :         let t = TenantId::default();
    1575            4 :         let own = "urn:ngsi-ld:Subscription:own";
    1576            4 :         ds_put(&st, &t, own, json!({"csr_sub": "urn:csr:1"})).await;
    1577            4 :         assert!(
    1578            4 :             ds_set_remote(
    1579            4 :                 &st,
    1580            4 :                 &t,
    1581            4 :                 own,
    1582            4 :                 "urn:reg:1",
    1583            4 :                 Some(json!(["http://s", "urn:remote:1"]))
    1584            4 :             )
    1585            4 :             .await
    1586              :         );
    1587            4 :         assert!(
    1588            4 :             !ds_set_remote(
    1589            4 :                 &st,
    1590            4 :                 &t,
    1591            4 :                 own,
    1592            4 :                 "urn:reg:1",
    1593            4 :                 Some(json!(["http://s", "urn:remote:2"]))
    1594            4 :             )
    1595            4 :             .await,
    1596              :             "the registration already has a remote subscription"
    1597              :         );
    1598            4 :         let got = ds_remotes(&ds_get(&st, &t, own).await);
    1599            4 :         assert_eq!(got.len(), 1);
    1600            4 :         assert_eq!(
    1601            4 :             got[0].1 .1, "urn:remote:1",
    1602            4 :             "the first mapping survives — the loser rolls its own copy back"
    1603            4 :         );
    1604            4 :     }
    1605              : 
    1606              :     /// 4.14: "the Tenant information from the Context Source Registration has
    1607              :     /// to be used" — the 5.8.5.4 delete-forward must travel with the stored
    1608              :     /// registration, not a synthetic id/endpoint pair, or it lands in the
    1609              :     /// peer's default tenant and the remote subscription is never deleted.
    1610              :     /// The synthetic fallback stays for the already-deleted registration.
    1611              :     #[test]
    1612            4 :     fn clause_5_8_5_delete_forward_carries_the_stored_registration() {
    1613            4 :         let stored = json!({
    1614            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:r1",
    1615            4 :             "endpoint": "http://source.example.org",
    1616            4 :             "tenant": "cityB",
    1617            4 :             "contextSourceInfo": [{"key": "Authorization", "value": "Bearer t"}],
    1618              :         });
    1619            4 :         let reg = forward_reg(
    1620            4 :             Some(stored.clone()),
    1621            4 :             "urn:ngsi-ld:ContextSourceRegistration:r1",
    1622            4 :             "http://source.example.org",
    1623              :         );
    1624            4 :         assert_eq!(reg["tenant"], json!("cityB"), "{reg}");
    1625            4 :         assert_eq!(reg["contextSourceInfo"], stored["contextSourceInfo"]);
    1626            4 :         let gone = forward_reg(
    1627            4 :             None,
    1628            4 :             "urn:ngsi-ld:ContextSourceRegistration:r1",
    1629            4 :             "http://source.example.org",
    1630              :         );
    1631            4 :         assert_eq!(gone["endpoint"], json!("http://source.example.org"));
    1632            4 :         assert!(
    1633            4 :             gone.get("tenant").is_none(),
    1634              :             "a deleted registration carries nothing to forward with: {gone}"
    1635              :         );
    1636            4 :     }
    1637              : 
    1638              :     /// 5.8.1.4: "The @context to be used for sending Notifications related to
    1639              :     /// this Subscription shall be the one specified in the jsonldContext
    1640              :     /// field." The forwarded copy carries the subscriber's own terms (`q` is
    1641              :     /// stored verbatim), so it must be shipped under the Subscription's
    1642              :     /// @context — under the core context those terms name Attributes that do
    1643              :     /// not exist at the source.
    1644              :     #[test]
    1645            4 :     fn clause_5_8_1_forwarded_copy_is_shipped_under_the_subscription_context() {
    1646            4 :         let st = AppState::new("antares-ds-ctx".into());
    1647            4 :         let core = st.loader.core().source.clone();
    1648            4 :         let hosted = json!({"jsonldContext": "http://broker.example.org/jsonldContexts/abc",
    1649            4 :                             "__context": "http://example.org/ctx.jsonld"});
    1650            4 :         assert_eq!(
    1651            4 :             sub_ctx_url(&st, &hosted),
    1652              :             "http://broker.example.org/jsonldContexts/abc",
    1653              :             "jsonldContext wins — it is the member 5.8.1.4 names"
    1654              :         );
    1655            4 :         let own = json!({"__context": "http://example.org/ctx.jsonld"});
    1656            4 :         assert_eq!(sub_ctx_url(&st, &own), "http://example.org/ctx.jsonld");
    1657            4 :         assert_ne!(
    1658            4 :             sub_ctx_url(&st, &own),
    1659            4 :             crate::federation::ctx_link_url(&HeaderMap::new(), &core),
    1660              :             "a Subscription with its own vocabulary is not forwarded under \
    1661              :              the core context"
    1662              :         );
    1663            4 :         assert_eq!(
    1664            4 :             sub_ctx_url(&st, &json!({})),
    1665            4 :             crate::federation::ctx_link_url(&HeaderMap::new(), &core),
    1666              :             "with no context of its own the core context is the fallback"
    1667              :         );
    1668            4 :     }
    1669              : 
    1670              :     /// 5.8.1.4 stores three mappings; every store touch takes the tenant
    1671              :     /// first (4.14: "an NGSI-LD system shall behave as if the tenants were
    1672              :     /// separate systems"). The inbound index is keyed by the
    1673              :     /// broker-generated remote subscriptionId under its own reserved
    1674              :     /// tenant, so it never collides with a tenant's own mapping documents.
    1675              :     #[tokio::test]
    1676            4 :     async fn clause_5_8_1_mappings_are_tenant_scoped() {
    1677            4 :         let st = AppState::new("antares-ds-tenant".into());
    1678            4 :         let a = TenantId::new("alpha").expect("tenant");
    1679            4 :         let b = TenantId::new("beta").expect("tenant");
    1680            4 :         let own = "urn:ngsi-ld:Subscription:own";
    1681            4 :         ds_put(
    1682            4 :             &st,
    1683            4 :             &a,
    1684            4 :             own,
    1685            4 :             json!({"csr_sub": "urn:csr:1",
    1686            4 :                    "remotes": {"urn:reg:1": ["http://s", "urn:remote:1"]}}),
    1687            4 :         )
    1688            4 :         .await;
    1689            4 :         assert_eq!(ds_remotes(&ds_get(&st, &a, own).await).len(), 1);
    1690            4 :         assert!(
    1691            4 :             ds_remotes(&ds_get(&st, &b, own).await).is_empty(),
    1692              :             "another tenant must not read the remote mapping"
    1693              :         );
    1694            4 :         assert!(ds_get(&st, &b, own).await.get("csr_sub").is_none());
    1695            4 :         inbound_put(&st, "urn:remote:1", &a, own).await;
    1696            4 :         assert_eq!(
    1697            4 :             inbound_get(&st, "urn:remote:1").await,
    1698            4 :             Some(("alpha".to_owned(), own.to_owned()))
    1699              :         );
    1700            4 :         assert!(
    1701            4 :             st.store
    1702            4 :                 .get(&a, Kind::DistSub, "urn:remote:1")
    1703            4 :                 .await
    1704            4 :                 .ok()
    1705            4 :                 .flatten()
    1706            4 :                 .is_none(),
    1707              :             "the index entry must not land in the subscriber's own namespace"
    1708              :         );
    1709            4 :         inbound_delete(&st, "urn:remote:1").await;
    1710            4 :         assert!(inbound_get(&st, "urn:remote:1").await.is_none());
    1711            4 :     }
    1712              : 
    1713              :     /// 4.14 — "an NGSI-LD system shall behave as if the tenants were
    1714              :     /// separate systems" — through the one index every tenant's inbound
    1715              :     /// mappings share. Its key is the remote subscriptionId the Context
    1716              :     /// Source echoes back on every notification (5.8.1.4), and the tenant
    1717              :     /// the index names is the tenant the data is delivered to: nothing later
    1718              :     /// on that path re-derives it. So a mapping claiming a key another
    1719              :     /// tenant already holds must lose. An index where the newer write won
    1720              :     /// would let a Context Source hand one tenant's notifications to another
    1721              :     /// tenant's subscriber by returning a subscriptionId already in use.
    1722              :     #[tokio::test]
    1723            4 :     async fn clause_5_8_1_an_inbound_key_is_never_taken_from_the_tenant_holding_it() {
    1724            4 :         let st = AppState::new("antares-ds-claim".into());
    1725            4 :         let a = TenantId::new("alpha").expect("tenant");
    1726            4 :         let b = TenantId::new("beta").expect("tenant");
    1727            4 :         let shared = "urn:ngsi-ld:Subscription:distsub:collide";
    1728            4 :         inbound_put(&st, shared, &a, "urn:ngsi-ld:Subscription:a").await;
    1729            4 :         inbound_put(&st, shared, &b, "urn:ngsi-ld:Subscription:b").await;
    1730            4 :         assert_eq!(
    1731            4 :             inbound_get(&st, shared).await,
    1732            4 :             Some(("alpha".to_owned(), "urn:ngsi-ld:Subscription:a".to_owned())),
    1733            4 :             "the second tenant took the first tenant's inbound route"
    1734            4 :         );
    1735            4 :     }
    1736              : 
    1737              :     /// 5.8.2.4 keeps the Registration Subscription in step by mutating the
    1738              :     /// stored mapping document. That document comes out of the store, and
    1739              :     /// `Value`'s index panics on anything that is not an object or Null, so
    1740              :     /// the two shapes a driver can hand back — a mapping that is not an
    1741              :     /// object, and one whose `notification` member is not an object — decide
    1742              :     /// whether an update of an ordinary Subscription takes the process down.
    1743              :     /// The panic would land inside the closure `mutate` runs under the
    1744              :     /// store's write lock, so it is not one request that is lost.
    1745              :     #[tokio::test]
    1746            4 :     async fn clause_5_8_2_a_stored_mapping_of_the_wrong_shape_is_not_indexed() {
    1747           24 :         for stored in [
    1748            4 :             json!("not an object"),
    1749            4 :             json!([1, 2, 3]),
    1750            4 :             json!(7),
    1751            4 :             Value::Null,
    1752            4 :             json!({"notification": "not an object"}),
    1753            4 :             json!({"notification": [1]}),
    1754            4 :         ] {
    1755           24 :             let st = AppState::new("antares-ds-shape".into());
    1756           24 :             let tenant = TenantId::new("shape").expect("tenant");
    1757           24 :             let own = "urn:ngsi-ld:Subscription:shape";
    1758           24 :             st.store
    1759           24 :                 .create(
    1760           24 :                     &tenant,
    1761           24 :                     Kind::Subscription,
    1762           24 :                     own,
    1763           24 :                     json!({"id": own, "type": "Subscription",
    1764           24 :                            "entities": [{"type": "Vehicle"}],
    1765           24 :                            "notification": {"endpoint": {"uri": "http://127.0.0.1:9/n"},
    1766           24 :                                             "attributes": ["speed"]}}),
    1767           24 :                 )
    1768           24 :                 .await
    1769           24 :                 .expect("seed the subscription");
    1770           24 :             ds_put(&st, &tenant, own, json!({"csr_sub": "urn:csr:shape"})).await;
    1771           24 :             st.store
    1772           24 :                 .create(&tenant, Kind::DistSub, "urn:csr:shape", stored.clone())
    1773           24 :                 .await
    1774           24 :                 .expect("seed the mapping");
    1775            4 : 
    1776           24 :             on_subscription_updated(&st, &tenant, own).await;
    1777            4 : 
    1778            4 :             // the update completes, and a mapping that could not be brought
    1779            4 :             // into step is left as it was rather than half-written
    1780           24 :             let after = st
    1781           24 :                 .store
    1782           24 :                 .get(&tenant, Kind::DistSub, "urn:csr:shape")
    1783           24 :                 .await
    1784           24 :                 .expect("read the mapping");
    1785           24 :             assert!(after.is_some(), "{stored}: the mapping was dropped");
    1786            4 :         }
    1787            4 :     }
    1788              : 
    1789              :     /// The same path on a well-formed mapping still carries the update
    1790              :     /// through: the guard above refuses a shape, it does not refuse the work.
    1791              :     #[tokio::test]
    1792            4 :     async fn clause_5_8_2_a_well_formed_mapping_is_brought_into_step() {
    1793            4 :         let st = AppState::new("antares-ds-step".into());
    1794            4 :         let tenant = TenantId::new("step").expect("tenant");
    1795            4 :         let own = "urn:ngsi-ld:Subscription:step";
    1796            4 :         st.store
    1797            4 :             .create(
    1798            4 :                 &tenant,
    1799            4 :                 Kind::Subscription,
    1800            4 :                 own,
    1801            4 :                 json!({"id": own, "type": "Subscription",
    1802            4 :                        "entities": [{"type": "Bus"}],
    1803            4 :                        "scopeQ": "/a/b",
    1804            4 :                        "notification": {"endpoint": {"uri": "http://127.0.0.1:9/n"},
    1805            4 :                                         "attributes": ["speed"]}}),
    1806            4 :             )
    1807            4 :             .await
    1808            4 :             .expect("seed the subscription");
    1809            4 :         ds_put(&st, &tenant, own, json!({"csr_sub": "urn:csr:step"})).await;
    1810            4 :         st.store
    1811            4 :             .create(
    1812            4 :                 &tenant,
    1813            4 :                 Kind::DistSub,
    1814            4 :                 "urn:csr:step",
    1815            4 :                 json!({"id": "urn:csr:step", "type": "Subscription",
    1816            4 :                        "entities": [{"type": "Tram"}],
    1817            4 :                        "notification": {"endpoint": {"uri": "urn:antares:distsub:step"}}}),
    1818            4 :             )
    1819            4 :             .await
    1820            4 :             .expect("seed the mapping");
    1821              : 
    1822            4 :         on_subscription_updated(&st, &tenant, own).await;
    1823              : 
    1824            4 :         let after = st
    1825            4 :             .store
    1826            4 :             .get(&tenant, Kind::DistSub, "urn:csr:step")
    1827            4 :             .await
    1828            4 :             .ok()
    1829            4 :             .flatten()
    1830            4 :             .expect("the mapping is still there");
    1831            4 :         assert_eq!(
    1832            4 :             after["entities"],
    1833            4 :             json!([{"type": "Bus"}]),
    1834              :             "the match members follow the Subscription: {after}"
    1835              :         );
    1836            4 :         assert_eq!(after["scopeQ"], "/a/b", "{after}");
    1837            4 :         assert_eq!(
    1838            4 :             after["notification"]["attributes"],
    1839            4 :             json!(["speed"]),
    1840              :             "5.8.2.4: the notification attributes follow too: {after}"
    1841              :         );
    1842            4 :         assert!(
    1843            4 :             after["notification"]["endpoint"]["uri"]
    1844            4 :                 .as_str()
    1845            4 :                 .is_some_and(|u| u.starts_with("urn:antares:distsub:")),
    1846            4 :             "the endpoint the copy points at is not rewritten: {after}"
    1847            4 :         );
    1848            4 :     }
    1849              : 
    1850              :     /// The remotes index is read back from storage, so every malformed
    1851              :     /// shape is dropped rather than indexed or unwrapped.
    1852              :     #[test]
    1853            4 :     fn clause_5_8_1_remotes_index_drops_malformed_entries() {
    1854            4 :         let doc = json!({"remotes": {
    1855            4 :             "urn:reg:ok": ["http://s", "urn:remote:1"],
    1856            4 :             "urn:reg:short": ["http://s"],
    1857            4 :             "urn:reg:nonstring": [1, 2],
    1858            4 :             "urn:reg:notarray": "http://s",
    1859            4 :             "urn:reg:empty": [],
    1860              :         }});
    1861            4 :         let got = ds_remotes(&doc);
    1862            4 :         assert_eq!(got.len(), 1, "{got:?}");
    1863            4 :         assert_eq!(got[0].0, "urn:reg:ok");
    1864            4 :         assert_eq!(got[0].1 .1, "urn:remote:1");
    1865            4 :         assert!(ds_remotes(&json!({"remotes": "nope"})).is_empty());
    1866            4 :         assert!(ds_remotes(&json!({})).is_empty());
    1867            4 :     }
    1868              : 
    1869              :     /// 5.8.1.4 / 5.8.5.4: the per-registration branches interleave at their
    1870              :     /// forward, so one registration's mapping write must not drop another's,
    1871              :     /// and a mapping document deleted by Delete Subscription must stay
    1872              :     /// deleted — an in-flight branch may not write it back.
    1873              :     #[tokio::test]
    1874            4 :     async fn clause_5_8_5_mapping_writes_never_resurrect_a_deleted_subscription() {
    1875            4 :         let st = AppState::new("antares-ds-resurrect".into());
    1876            4 :         let t = TenantId::default();
    1877            4 :         let own = "urn:ngsi-ld:Subscription:own";
    1878            4 :         ds_put(&st, &t, own, json!({"csr_sub": "urn:csr:1"})).await;
    1879            4 :         assert!(
    1880            4 :             ds_set_remote(
    1881            4 :                 &st,
    1882            4 :                 &t,
    1883            4 :                 own,
    1884            4 :                 "urn:reg:1",
    1885            4 :                 Some(json!(["http://s1", "urn:remote:1"]))
    1886            4 :             )
    1887            4 :             .await
    1888              :         );
    1889            4 :         assert!(
    1890            4 :             ds_set_remote(
    1891            4 :                 &st,
    1892            4 :                 &t,
    1893            4 :                 own,
    1894            4 :                 "urn:reg:2",
    1895            4 :                 Some(json!(["http://s2", "urn:remote:2"]))
    1896            4 :             )
    1897            4 :             .await
    1898              :         );
    1899            4 :         assert_eq!(
    1900            4 :             ds_remotes(&ds_get(&st, &t, own).await).len(),
    1901              :             2,
    1902              :             "a second registration's write must not drop the first"
    1903              :         );
    1904            4 :         assert!(ds_set_remote(&st, &t, own, "urn:reg:1", None).await);
    1905            4 :         assert_eq!(ds_remotes(&ds_get(&st, &t, own).await).len(), 1);
    1906            4 :         let _ = st.store.delete(&t, Kind::DistSub, own).await;
    1907            4 :         assert!(
    1908            4 :             !ds_set_remote(
    1909            4 :                 &st,
    1910            4 :                 &t,
    1911            4 :                 own,
    1912            4 :                 "urn:reg:3",
    1913            4 :                 Some(json!(["http://s3", "urn:remote:3"]))
    1914            4 :             )
    1915            4 :             .await,
    1916              :             "there is no mapping document left to write to"
    1917              :         );
    1918            4 :         assert!(
    1919            4 :             st.store
    1920            4 :                 .get(&t, Kind::DistSub, own)
    1921            4 :                 .await
    1922            4 :                 .ok()
    1923            4 :                 .flatten()
    1924            4 :                 .is_none(),
    1925            4 :             "a deleted mapping document must stay deleted"
    1926            4 :         );
    1927            4 :     }
    1928              : 
    1929              :     /// The inbound notification endpoint is peer-facing: the Entity count is
    1930              :     /// capped before any store touch, because the 5.8.6 merge runs one local
    1931              :     /// retrieve and one federated fan-out per notified Entity.
    1932              :     #[tokio::test]
    1933            4 :     async fn clause_5_8_6_inbound_notification_entity_count_is_capped() {
    1934            4 :         let st = AppState::new("antares-ds-cap".into());
    1935            4 :         let cap = *crate::bounds::MAX_BATCH_ITEMS;
    1936            8 :         let body = |n: usize| {
    1937            8 :             json!({
    1938            8 :                 "type": "Notification",
    1939            8 :                 "subscriptionId": "urn:ngsi-ld:Subscription:remote1",
    1940            8 :                 "data": vec![json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle"}); n],
    1941              :             })
    1942            8 :             .to_string()
    1943            8 :         };
    1944            4 :         let over = remote_notify(State(st.clone()), Bytes::from(body(cap + 1))).await;
    1945            4 :         assert_eq!(
    1946            4 :             over.status(),
    1947              :             StatusCode::BAD_REQUEST,
    1948              :             "an over-cap notification is rejected before the mapping lookup"
    1949              :         );
    1950            4 :         let at_cap = remote_notify(State(st.clone()), Bytes::from(body(cap))).await;
    1951            4 :         assert_eq!(
    1952            4 :             at_cap.status(),
    1953            4 :             StatusCode::NOT_FOUND,
    1954            4 :             "the ceiling itself is accepted — the unknown mapping is what stops it"
    1955            4 :         );
    1956            4 :     }
    1957              : 
    1958              :     /// 5.8.1.4 stores the mapping "to enable forwarding received
    1959              :     /// notifications to the original subscriber": once that subscriber is
    1960              :     /// gone the mapping is dead weight, so it is pruned on touch, and the
    1961              :     /// peer is answered about its own id — never told the local one.
    1962              :     #[tokio::test]
    1963            4 :     async fn clause_5_8_1_mapping_to_a_deleted_subscription_is_pruned() {
    1964            4 :         let st = AppState::new("antares-ds-prune".into());
    1965            4 :         let t = TenantId::new("alpha").expect("tenant");
    1966            4 :         let remote = "urn:ngsi-ld:Subscription:remote9";
    1967            4 :         inbound_put(&st, remote, &t, "urn:ngsi-ld:Subscription:gone").await;
    1968            4 :         let body =
    1969            4 :             json!({"type": "Notification", "subscriptionId": remote, "data": []}).to_string();
    1970            4 :         let resp = remote_notify(State(st.clone()), Bytes::from(body)).await;
    1971            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    1972            4 :         assert!(
    1973            4 :             inbound_get(&st, remote).await.is_none(),
    1974              :             "the dead mapping must not survive the touch"
    1975              :         );
    1976            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1977            4 :             .await
    1978            4 :             .expect("body");
    1979            4 :         let text = String::from_utf8_lossy(&bytes);
    1980            4 :         assert!(
    1981            4 :             !text.contains("urn:ngsi-ld:Subscription:gone"),
    1982            4 :             "the local Subscription id must not leak to the peer: {text}"
    1983            4 :         );
    1984            4 :     }
    1985              : 
    1986              :     /// 5.8.1.4 consumer half, as a seam: the delivery path hands a
    1987              :     /// notification on the internal endpoint to the handler `wire` installs
    1988              :     /// and to nothing else. An unwired state has no handler, so such a
    1989              :     /// notification is dropped rather than delivered somewhere — the seam
    1990              :     /// fails closed, and a caller cannot reach `distsub` by bypassing it.
    1991              :     #[tokio::test]
    1992            4 :     async fn the_internal_endpoint_is_reached_only_through_the_installed_handler() {
    1993            4 :         let bare = AppState::new("antares".into());
    1994            4 :         assert!(
    1995            4 :             bare.csource_notification.is_none(),
    1996              :             "a state nobody wired carries no handler"
    1997              :         );
    1998            4 :         let wired = crate::wired_state("antares");
    1999            4 :         assert!(
    2000            4 :             wired.await.csource_notification.is_some(),
    2001            4 :             "wire installs the consumer half"
    2002            4 :         );
    2003            4 :     }
    2004              : 
    2005              :     /// 5.8.6 forwards an inbound notification to the original subscriber,
    2006              :     /// and that delivery is a notification like any other: it passes
    2007              :     /// `pre_notify` once, under the subject stored with the ORIGINAL
    2008              :     /// Subscription, so a narrowing the subscriber is under holds for data a
    2009              :     /// remote source sent (operation matrix section 8 item 13).
    2010              :     #[tokio::test(flavor = "multi_thread")]
    2011            4 :     async fn a_remote_notification_is_narrowed_for_the_original_subscriber() {
    2012              :         use crate::policy::{
    2013              :             Decision, DecisionFuture, NotifyDecision, Operation, PolicyEngine, Subject,
    2014              :         };
    2015              :         use std::sync::{Arc, Mutex};
    2016              : 
    2017              :         /// Strips `secret` from every notified Entity and records the
    2018              :         /// subject it was asked about.
    2019              :         struct OmitSecret(Mutex<Vec<Vec<(String, String)>>>);
    2020              :         impl PolicyEngine for OmitSecret {
    2021            0 :             fn name(&self) -> &str {
    2022            0 :                 "omit-secret"
    2023            0 :             }
    2024            0 :             fn decide<'a>(&'a self, _s: &'a Subject, _op: &'a Operation<'a>) -> DecisionFuture<'a> {
    2025            0 :                 Box::pin(std::future::ready(Decision::Allow))
    2026            0 :             }
    2027            4 :             fn pre_notify(&self, s: &Subject, _sub: &Value, n: &mut Value) -> NotifyDecision {
    2028            4 :                 self.0.lock().expect("subjects").push(s.headers.clone());
    2029            4 :                 for e in n
    2030            4 :                     .get_mut("data")
    2031            4 :                     .and_then(Value::as_array_mut)
    2032            4 :                     .into_iter()
    2033            4 :                     .flatten()
    2034              :                 {
    2035            4 :                     if let Some(o) = e.as_object_mut() {
    2036            4 :                         o.remove("secret");
    2037            4 :                     }
    2038              :                 }
    2039            4 :                 NotifyDecision::Deliver
    2040            4 :             }
    2041              :         }
    2042              : 
    2043            4 :         let seen: Arc<Mutex<Vec<Value>>> = Arc::default();
    2044            4 :         let sink = seen.clone();
    2045            4 :         let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
    2046            4 :             .await
    2047            4 :             .expect("bind");
    2048            4 :         let addr = listener.local_addr().expect("addr");
    2049            4 :         let app = axum::Router::new().route(
    2050            4 :             "/notify",
    2051            4 :             axum::routing::post(move |body: String| {
    2052            4 :                 let sink = sink.clone();
    2053            4 :                 async move {
    2054            4 :                     if let Ok(v) = serde_json::from_str::<Value>(&body) {
    2055            4 :                         sink.lock().expect("bodies").push(v);
    2056            4 :                     }
    2057            4 :                     StatusCode::OK
    2058            4 :                 }
    2059            4 :             }),
    2060              :         );
    2061            4 :         tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
    2062              : 
    2063            4 :         let engine = Arc::new(OmitSecret(Mutex::default()));
    2064            4 :         let st = AppState::new("antares-ds-narrow".into()).with_policy(engine.clone());
    2065            4 :         let t = TenantId::new("alpha").expect("tenant");
    2066            4 :         let own = "urn:ngsi-ld:Subscription:own-narrowed";
    2067            4 :         st.store
    2068            4 :             .create(
    2069            4 :                 &t,
    2070            4 :                 Kind::Subscription,
    2071            4 :                 own,
    2072            4 :                 json!({
    2073            4 :                     "id": own,
    2074            4 :                     "type": "Subscription",
    2075            4 :                     "entities": [{"type": format!("{DC}Vehicle")}],
    2076            4 :                     "notification": {"endpoint": {
    2077            4 :                         "uri": format!("http://{addr}/notify"),
    2078            4 :                         "accept": "application/json",
    2079            4 :                     }},
    2080            4 :                     crate::policy::SUBJECT_MEMBER: [["x-user", "alice"]],
    2081            4 :                 }),
    2082            4 :             )
    2083            4 :             .await
    2084            4 :             .expect("subscription row");
    2085            4 :         let remote = "urn:ngsi-ld:Subscription:remote-narrowed";
    2086            4 :         inbound_put(&st, remote, &t, own).await;
    2087              : 
    2088            4 :         let body = json!({
    2089            4 :             "type": "Notification",
    2090            4 :             "subscriptionId": remote,
    2091            4 :             "data": [{
    2092            4 :                 "id": "urn:ngsi-ld:Vehicle:1",
    2093            4 :                 "type": "Vehicle",
    2094            4 :                 "speed": {"type": "Property", "value": 50},
    2095            4 :                 "secret": {"type": "Property", "value": "hidden"},
    2096              :             }],
    2097              :         })
    2098            4 :         .to_string();
    2099            4 :         let resp = remote_notify(State(st.clone()), Bytes::from(body)).await;
    2100            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2101              : 
    2102            4 :         let mut delivered = None;
    2103            4 :         for _ in 0..100 {
    2104            8 :             if let Some(b) = seen.lock().expect("bodies").first().cloned() {
    2105            4 :                 delivered = Some(b);
    2106            4 :                 break;
    2107            4 :             }
    2108            4 :             tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    2109              :         }
    2110            4 :         let delivered = delivered.expect("the original subscriber was notified");
    2111            4 :         let entity = &delivered["data"][0];
    2112            4 :         assert_eq!(entity["id"], "urn:ngsi-ld:Vehicle:1");
    2113            4 :         assert!(
    2114            4 :             entity.get("speed").is_some(),
    2115              :             "visible data travels: {delivered}"
    2116              :         );
    2117            4 :         assert!(
    2118            4 :             entity.get("secret").is_none(),
    2119              :             "the subscriber's narrowing holds for remote data: {delivered}"
    2120              :         );
    2121            4 :         assert_eq!(
    2122            4 :             delivered["subscriptionId"], own,
    2123              :             "delivered under the subscriber's own id"
    2124              :         );
    2125            4 :         assert_eq!(
    2126            4 :             *engine.0.lock().expect("subjects"),
    2127            4 :             vec![vec![("x-user".to_owned(), "alice".to_owned())]],
    2128            4 :             "asked once, about the original subscriber"
    2129            4 :         );
    2130            4 :     }
    2131              : }
        

Generated by: LCOV version 2.0-1