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