LCOV - code coverage report
Current view: top level - antares-api/src - subscriptions.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.4 % 1326 1291
Test Date: 2026-09-21 10:31:06 Functions: 49.0 % 488 239

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! /subscriptions and /csourceSubscriptions (5.8, 5.11; resources 6.10/6.11,
       3              : //! 6.12/6.13). One implementation, two store kinds — both use the
       4              : //! Subscription data type (5.2.12).
       5              : 
       6              : use crate::negotiate::*;
       7              : use crate::state::{now_iso, AppState};
       8              : use antares_jsonld::{parse_datetime, Context};
       9              : use antares_model::{NgsiError, TenantId};
      10              : use antares_store::CurrentStateDriverExt;
      11              : use antares_store::Kind;
      12              : use axum::body::Bytes;
      13              : use axum::extract::{Path, State};
      14              : use axum::http::{HeaderMap, StatusCode};
      15              : use axum::response::{IntoResponse, Response};
      16              : use serde_json::{Map, Value};
      17              : use std::collections::HashMap;
      18              : 
      19              : use crate::negotiate::CleanParams;
      20              : 
      21          438 : fn resource_path(kind: Kind) -> &'static str {
      22          438 :     match kind {
      23           62 :         Kind::CSourceSubscription => "csourceSubscriptions",
      24          376 :         _ => "subscriptions",
      25              :     }
      26          438 : }
      27              : 
      28              : /// Table 5.2.33-1 EntitySelector: the `entities` member of a Subscription
      29              : /// (5.2.12). A selector needs a type; ids are URIs, an idPattern is a
      30              : /// regular expression, and a type-selection expression (4.17) stays raw
      31              : /// because it is evaluated at match time.
      32         1248 : fn norm_entity_selectors(v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
      33         1248 :     let bad = NgsiError::BadRequestData;
      34         1248 :     let arr = v
      35         1248 :         .as_array()
      36         1248 :         .filter(|a| !a.is_empty())
      37         1248 :         .ok_or_else(|| bad("entities must be a non-empty array".into()))?;
      38         1240 :     let mut entities = Vec::new();
      39         1240 :     for e in arr {
      40         1240 :         let eo = e
      41         1240 :             .as_object()
      42         1240 :             .ok_or_else(|| bad("entities entries must be objects".into()))?;
      43         1236 :         let mut ne = Map::new();
      44         1246 :         for (ek, ev) in eo {
      45         1246 :             match ek.as_str() {
      46         1246 :                 "type" => {
      47         1208 :                     let t = ev
      48         1208 :                         .as_str()
      49         1208 :                         .filter(|t| !t.is_empty())
      50         1208 :                         .ok_or_else(|| bad("EntitySelector type is required".into()))?;
      51              :                     // 4.17 type-selection expressions stay raw and
      52              :                     // are evaluated at match time (046_16). Table
      53              :                     // 5.2.33-1's "*" — "a request for all
      54              :                     // Entities" — is not a term either: expanded,
      55              :                     // it becomes an IRI no entity carries and the
      56              :                     // subscription notifies nothing.
      57         1204 :                     if t == "*" || t.contains(['|', ',', ';', '(']) {
      58            8 :                         ne.insert("type".into(), ev.clone());
      59         1196 :                     } else {
      60         1196 :                         ne.insert("type".into(), Value::String(ctx.expand_key(t)));
      61         1196 :                     }
      62              :                 }
      63           38 :                 "id" => {
      64              :                     // Table 5.2.33-1: id is "String or String[]"
      65              :                     // of valid URIs
      66           24 :                     match ev {
      67            8 :                         Value::String(id) => {
      68            8 :                             antares_model::EntityId::new(id)?;
      69              :                         }
      70           16 :                         Value::Array(a) => {
      71           32 :                             for i in a {
      72           32 :                                 let id = i.as_str().ok_or_else(|| {
      73            8 :                                     bad("EntitySelector id entries must be URIs (5.2.33)".into())
      74            8 :                                 })?;
      75           24 :                                 antares_model::EntityId::new(id)?;
      76              :                             }
      77              :                         }
      78              :                         _ => {
      79            0 :                             return Err(bad(
      80            0 :                                 "EntitySelector id must be a URI string or array (5.2.33)".into(),
      81            0 :                             ))
      82              :                         }
      83              :                     }
      84           12 :                     ne.insert("id".into(), ev.clone());
      85              :                 }
      86           14 :                 "idPattern" => {
      87           14 :                     let p = ev
      88           14 :                         .as_str()
      89           14 :                         .ok_or_else(|| bad("idPattern must be a string".into()))?;
      90           10 :                     antares_ql::regex::compile(p)
      91           10 :                         .map_err(|_| bad(format!("invalid idPattern {p:?}")))?;
      92            2 :                     ne.insert("idPattern".into(), ev.clone());
      93              :                 }
      94            0 :                 _ => {
      95            0 :                     ne.insert(ek.clone(), ev.clone());
      96            0 :                 }
      97              :             }
      98              :         }
      99         1208 :         if !ne.contains_key("type") {
     100            4 :             return Err(bad("EntitySelector requires type (5.2.33)".into()));
     101         1204 :         }
     102         1204 :         entities.push(Value::Object(ne));
     103              :     }
     104         1204 :     Ok(Value::Array(entities))
     105         1248 : }
     106              : 
     107              : /// Table 5.2.14.1-1 NotificationParams: the `notification` member of a
     108              : /// Subscription (5.2.12). Output-only members are dropped, every other
     109              : /// member is held to its value space, and the Endpoint is checked by
     110              : /// `check_endpoint_params`.
     111         1136 : fn norm_notification(v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
     112         1136 :     let bad = NgsiError::BadRequestData;
     113         1136 :     let n = v
     114         1136 :         .as_object()
     115         1136 :         .ok_or_else(|| bad("notification must be an object (5.2.14)".into()))?;
     116         1132 :     let mut nn = n.clone();
     117              :     // 5.2.14.2: output-only members are read-only — provided
     118              :     // ones are ignored, never stored.
     119         5660 :     for k in [
     120         1132 :         "timesSent",
     121         1132 :         "timesFailed",
     122         1132 :         "lastNotification",
     123         1132 :         "lastSuccess",
     124         1132 :         "lastFailure",
     125         5660 :     ] {
     126         5660 :         nn.remove(k);
     127         5660 :     }
     128         1132 :     if let Some(f) = n.get("format").and_then(Value::as_str) {
     129           56 :         if !["normalized", "keyValues", "simplified", "concise"].contains(&f) {
     130            4 :             return Err(bad(format!("invalid notification format {f:?}")));
     131           52 :         }
     132         1076 :     }
     133              :     // Table 5.2.14.1-1 p.120: "showChanges cannot be true in case
     134              :     // format is keyValues" — "simplified" is the declared synonym
     135         1128 :     if n.get("showChanges").and_then(Value::as_bool) == Some(true)
     136           16 :         && matches!(
     137           28 :             n.get("format").and_then(Value::as_str),
     138           24 :             Some("keyValues") | Some("simplified")
     139              :         )
     140              :     {
     141           16 :         return Err(bad(
     142           16 :             "showChanges cannot be true when format is keyValues (5.2.14)".into(),
     143           16 :         ));
     144         1112 :     }
     145              :     // Table 5.2.14.1-1: join / joinLevel / sysAttrs /
     146              :     // showChanges value spaces.
     147         1112 :     if let Some(j) = n.get("join") {
     148           48 :         if !j
     149           48 :             .as_str()
     150           48 :             .is_some_and(|j| ["flat", "inline", "@none"].contains(&j))
     151              :         {
     152           16 :             return Err(bad(format!("invalid notification join {j:?} (5.2.14)")));
     153           32 :         }
     154         1064 :     }
     155              :     // Table 5.2.14.1-1: a positive integer. The depth it names is
     156              :     // the same Linked Entity traversal (4.5.23) a query drives, so
     157              :     // it carries the same ceiling — every notification of this
     158              :     // Subscription pays that traversal, and an unbounded level
     159              :     // makes one accepted Subscription an amplification lever.
     160         1096 :     if let Some(jl) = n.get("joinLevel") {
     161           56 :         let cap = crate::bounds::MAX_JOIN_LEVEL as u64;
     162           56 :         let ok = jl.as_u64().is_some_and(|v| (1..=cap).contains(&v));
     163           56 :         if !ok {
     164           32 :             return Err(bad(format!(
     165           32 :                 "notification.joinLevel must be an integer in 1..={cap} (5.2.14)"
     166           32 :             )));
     167           24 :         }
     168         1040 :     }
     169         2120 :     for key in ["sysAttrs", "showChanges"] {
     170         2120 :         if n.get(key).is_some_and(|v| !v.is_boolean()) {
     171           16 :             return Err(bad(format!(
     172           16 :                 "notification.{key} must be a boolean (5.2.14)"
     173           16 :             )));
     174         2104 :         }
     175              :     }
     176         1048 :     if let Some(attrs) = n.get("attributes").and_then(Value::as_array) {
     177              :         // Table 5.2.14.1-1 p.119: "Empty array (0 length) is not
     178              :         // allowed" — same restriction on pick and omit below
     179           44 :         if attrs.is_empty() {
     180            8 :             return Err(bad(
     181            8 :                 "notification.attributes must not be empty (5.2.14)".into()
     182            8 :             ));
     183           36 :         }
     184           36 :         let mut na = Vec::new();
     185           36 :         for a in attrs {
     186           36 :             let s = a
     187           36 :                 .as_str()
     188           36 :                 .ok_or_else(|| bad("notification.attributes must be strings".into()))?;
     189              :             // "A synonym for pick, except that id, type, scope
     190              :             // are not allowed."
     191           32 :             if ["id", "type", "scope"].contains(&s) {
     192           16 :                 return Err(bad(format!(
     193           16 :                     "notification.attributes may not name {s:?} (5.2.14)"
     194           16 :                 )));
     195           16 :             }
     196           16 :             na.push(Value::String(ctx.expand_key(s)));
     197              :         }
     198           16 :         nn.insert("attributes".into(), Value::Array(na));
     199         1004 :     }
     200         2004 :     for key in ["pick", "omit"] {
     201         2004 :         let Some(members) = n.get(key).and_then(Value::as_array) else {
     202         1892 :             continue;
     203              :         };
     204          112 :         if members.is_empty() {
     205           16 :             return Err(bad(format!(
     206           16 :                 "notification.{key} must not be empty (5.2.14)"
     207           16 :             )));
     208           96 :         }
     209              :         // Table 5.2.14.1-1: each member is "a valid attribute
     210              :         // projection language string as per clause 4.21". The
     211              :         // notification path parses it again at delivery and drops
     212              :         // a member it cannot parse, so an unparseable `omit`
     213              :         // accepted here would deliver the Attribute the subscriber
     214              :         // asked to have removed.
     215          118 :         for m in members {
     216          118 :             let term = m
     217          118 :                 .as_str()
     218          118 :                 .ok_or_else(|| bad(format!("notification.{key} members are Strings (5.2.14)")))?;
     219          110 :             crate::repr::parse_projection(term, ctx).map_err(|_| {
     220           48 :                 bad(format!(
     221           48 :                     "notification.{key} member {term:?} is not an attribute \
     222           48 :                      projection language string (4.21)"
     223           48 :                 ))
     224           48 :             })?;
     225              :         }
     226              :     }
     227          948 :     let ep = n
     228          948 :         .get("endpoint")
     229          948 :         .and_then(Value::as_object)
     230          948 :         .ok_or_else(|| bad("notification.endpoint is required (5.2.14)".into()))?;
     231          944 :     let uri = ep
     232          944 :         .get("uri")
     233          944 :         .and_then(Value::as_str)
     234          944 :         .ok_or_else(|| bad("endpoint.uri is required (5.2.15)".into()))?;
     235          936 :     antares_model::EntityId::new(uri)
     236          936 :         .map_err(|_| bad(format!("endpoint.uri is not a valid URI: {uri:?}")))?;
     237              : 
     238         1844 :     let member_names = |key: &str| -> Vec<String> {
     239         1844 :         n.get(key)
     240         1844 :             .and_then(Value::as_array)
     241         1844 :             .map(|a| {
     242           40 :                 a.iter()
     243           40 :                     .filter_map(Value::as_str)
     244           40 :                     .map(str::to_owned)
     245           40 :                     .collect()
     246           40 :             })
     247         1844 :             .unwrap_or_default()
     248         1844 :     };
     249          922 :     let pick = member_names("pick");
     250          922 :     let omit = member_names("omit");
     251          922 :     if !pick.is_empty() && n.contains_key("attributes") {
     252            4 :         return Err(bad("notification.pick and attributes are exclusive".into()));
     253          918 :     }
     254          918 :     if !omit.is_empty() && n.contains_key("attributes") {
     255            4 :         return Err(bad("notification.omit and attributes are exclusive".into()));
     256          914 :     }
     257          914 :     if pick.iter().any(|p| omit.contains(p)) {
     258            4 :         return Err(bad(
     259            4 :             "notification.pick and omit name the same entity member".into(),
     260            4 :         ));
     261          910 :     }
     262          910 :     check_endpoint_params(ep)?;
     263          722 :     Ok(Value::Object(nn))
     264         1136 : }
     265              : 
     266              : /// Table 5.2.15-1 Endpoint: the members that decide how a notification is
     267              : /// sent. `uri` is checked by the caller, which needs it first.
     268          910 : fn check_endpoint_params(ep: &Map<String, Value>) -> Result<(), NgsiError> {
     269          910 :     let bad = NgsiError::BadRequestData;
     270              :     // Table 5.2.15-1: receiverInfo/notifierInfo are
     271              :     // KeyValuePair[] — per Table 5.2.22-1 both key and value
     272              :     // are Strings, cardinality 1.
     273         1772 :     for key in ["receiverInfo", "notifierInfo"] {
     274         1772 :         if let Some(arr) = ep.get(key) {
     275          200 :             let ok = arr.as_array().is_some_and(|a| {
     276          198 :                 a.iter().all(|kv| {
     277          198 :                     kv.get("key").is_some_and(Value::is_string)
     278          178 :                         && kv.get("value").is_some_and(Value::is_string)
     279          198 :                 })
     280          192 :             });
     281          200 :             if !ok {
     282           72 :                 return Err(bad(format!(
     283           72 :                     "endpoint.{key} entries must be {{key, value}} pairs (5.2.15/5.2.22)"
     284           72 :                 )));
     285          128 :             }
     286         1572 :         }
     287              :     }
     288              :     // 6.3.8 and 6.3.9: each receiverInfo pair becomes one custom
     289              :     // header on the notification POST, and "'Key' and 'value'
     290              :     // members shall adhere to IETF RFC 7230 ... definitions
     291              :     // concerning HTTP headers". A pair that cannot be a header is
     292              :     // input the operation cannot meet (5.8.1.4), so it is refused
     293              :     // here rather than accepted into a Subscription that can only
     294              :     // ever dead-letter. notifierInfo is not headers — its own
     295              :     // binding validates it through the sink.
     296          838 :     if let Some(arr) = ep.get("receiverInfo").and_then(Value::as_array) {
     297          126 :         for kv in arr {
     298          126 :             let (k, v) = (kv["key"].as_str(), kv["value"].as_str());
     299          126 :             if !k.is_some_and(is_field_name) || !v.is_some_and(is_field_value) {
     300           76 :                 return Err(bad(format!(
     301           76 :                     "endpoint.receiverInfo entry {kv} is not a valid HTTP header \
     302           76 :                      (RFC 7230, 6.3.8)"
     303           76 :                 )));
     304           50 :             }
     305              :         }
     306          718 :     }
     307              :     // Table 5.2.15-1: cooldown and timeout are Numbers "Greater
     308              :     // than 0"
     309         1508 :     for key in ["cooldown", "timeout"] {
     310         1508 :         if let Some(v) = ep.get(key) {
     311           80 :             v.as_f64().filter(|n| *n > 0.0).ok_or_else(|| {
     312           32 :                 bad(format!(
     313           32 :                     "endpoint.{key} must be a number greater than 0 (5.2.15)"
     314           32 :                 ))
     315           32 :             })?;
     316         1428 :         }
     317              :     }
     318          730 :     if let Some(acc) = ep.get("accept").and_then(Value::as_str) {
     319           26 :         if ![
     320           26 :             "application/json",
     321           26 :             "application/ld+json",
     322           26 :             "application/geo+json",
     323           26 :         ]
     324           26 :         .contains(&acc)
     325              :         {
     326            8 :             return Err(bad(format!("invalid endpoint accept {acc:?}")));
     327           18 :         }
     328          704 :     }
     329          722 :     Ok(())
     330          910 : }
     331              : 
     332              : /// Table 5.2.12-1: "Valid notification triggers are entityCreated,
     333              : /// entityUpdated, entityDeleted, attributeCreated, attributeUpdated,
     334              : /// attributeDeleted." A trigger outside that set is accepted and then
     335              : /// matches nothing, leaving a subscription that never fires.
     336           42 : fn check_notification_triggers(v: &Value) -> Result<(), NgsiError> {
     337           42 :     let bad = NgsiError::BadRequestData;
     338              :     const TRIGGERS: [&str; 6] = [
     339              :         "entityCreated",
     340              :         "entityUpdated",
     341              :         "entityDeleted",
     342              :         "attributeCreated",
     343              :         "attributeUpdated",
     344              :         "attributeDeleted",
     345              :     ];
     346           42 :     let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| {
     347            8 :         bad("notificationTrigger must be a non-empty array of strings (5.2.12)".into())
     348            8 :     })?;
     349           50 :     for t in list {
     350           50 :         let t = t
     351           50 :             .as_str()
     352           50 :             .ok_or_else(|| bad("notificationTrigger entries must be strings (5.2.12)".into()))?;
     353           46 :         if !TRIGGERS.contains(&t) {
     354            8 :             return Err(bad(format!(
     355            8 :                 "{t} is not a valid notification trigger (5.2.12)"
     356            8 :             )));
     357           38 :         }
     358              :     }
     359           22 :     Ok(())
     360           42 : }
     361              : 
     362              : /// One member of Table 5.2.12-1, validated and normalized into `out`.
     363              : /// A member the table does not name is kept verbatim: 5.5.9 asks a
     364              : /// receiver to tolerate what it does not know.
     365         3880 : fn norm_member(
     366         3880 :     k: &str,
     367         3880 :     v: &Value,
     368         3880 :     ctx: &Context,
     369         3880 :     out: &mut Map<String, Value>,
     370         3880 : ) -> Result<(), NgsiError> {
     371         3880 :     let bad = NgsiError::BadRequestData;
     372           44 :     match k {
     373         3880 :         "@context" | "createdAt" | "modifiedAt" | "status" => return Ok(()),
     374         3850 :         "id" => {
     375          372 :             let id = v
     376          372 :                 .as_str()
     377          372 :                 .ok_or_else(|| bad("subscription id must be a string URI".into()))?;
     378          372 :             antares_model::EntityId::new(id)?;
     379          372 :             out.insert("id".into(), v.clone());
     380              :         }
     381         3478 :         "type" => {
     382          602 :             if v.as_str() != Some("Subscription") {
     383            0 :                 return Err(bad("type must be \"Subscription\" (5.2.12)".into()));
     384          602 :             }
     385          602 :             out.insert("type".into(), v.clone());
     386              :         }
     387         2876 :         "entities" => {
     388         1248 :             out.insert("entities".into(), norm_entity_selectors(v, ctx)?);
     389              :         }
     390         1628 :         "watchedAttributes" => {
     391           28 :             let arr = v
     392           28 :                 .as_array()
     393           28 :                 .filter(|a| !a.is_empty())
     394           28 :                 .ok_or_else(|| bad("watchedAttributes must be a non-empty array".into()))?;
     395           20 :             let mut attrs = Vec::new();
     396           20 :             for a in arr {
     397           20 :                 let s = a
     398           20 :                     .as_str()
     399           20 :                     .filter(|s| !s.is_empty())
     400           20 :                     .ok_or_else(|| bad("watchedAttributes entries must be strings".into()))?;
     401           12 :                 attrs.push(Value::String(ctx.expand_key(s)));
     402              :             }
     403           12 :             out.insert("watchedAttributes".into(), Value::Array(attrs));
     404              :         }
     405         1600 :         "q" => {
     406           38 :             let q = v.as_str().ok_or_else(|| bad("q must be a string".into()))?;
     407              :             // Validate the string the MATCHER will parse. `conditions_match`
     408              :             // percent-decodes first (4.9, 046_05), so validating the raw
     409              :             // form would let `%28%28%28…` through create-time checks and
     410              :             // only become thousands of real parens at notification time —
     411              :             // inside a spawned task, where the parser's own limits are the
     412              :             // last line of defence.
     413           34 :             let decoded = crate::negotiate::percent_decode(q.as_bytes());
     414           34 :             antares_ql::parse_q(&decoded)?;
     415           26 :             out.insert("q".into(), v.clone());
     416              :         }
     417         1562 :         "geoQ" => {
     418           36 :             let g = v
     419           36 :                 .as_object()
     420           36 :                 .ok_or_else(|| bad("geoQ must be an object".into()))?;
     421           32 :             antares_ql::geo::GeoQuery::from_params(&antares_matcher::geo_params(g))?
     422           12 :                 .ok_or_else(|| bad("geoQ requires georel (5.2.13)".into()))?;
     423           12 :             let mut ng = g.clone();
     424           12 :             if let Some(gp) = g.get("geoproperty").and_then(Value::as_str) {
     425            8 :                 ng.insert("geoproperty".into(), Value::String(ctx.expand_key(gp)));
     426            8 :             }
     427           12 :             out.insert("geoQ".into(), Value::Object(ng));
     428              :         }
     429         1526 :         "notification" => {
     430         1136 :             out.insert("notification".into(), norm_notification(v, ctx)?);
     431              :         }
     432          390 :         "expiresAt" => {
     433           48 :             let s = v
     434           48 :                 .as_str()
     435           48 :                 .filter(|s| parse_datetime(s))
     436           48 :                 .ok_or_else(|| bad("expiresAt must be an ISO 8601 DateTime".into()))?;
     437              :             // 4.6.3 admits several spellings of one instant, so whether a
     438              :             // DateTime has passed cannot be read off the raw strings.
     439           28 :             if antares_model::dt_key(s) < antares_model::dt_key(&now_iso()) {
     440           12 :                 return Err(bad("expiresAt is in the past (5.8.1)".into()));
     441           16 :             }
     442           16 :             out.insert("expiresAt".into(), v.clone());
     443              :         }
     444          342 :         "throttling" => {
     445           30 :             v.as_f64()
     446           30 :                 .filter(|n| *n > 0.0)
     447           30 :                 .ok_or_else(|| bad("throttling must be a positive number".into()))?;
     448           10 :             out.insert("throttling".into(), v.clone());
     449              :         }
     450          312 :         "timeInterval" => {
     451           40 :             v.as_f64()
     452           40 :                 .filter(|n| *n > 0.0)
     453           40 :                 .ok_or_else(|| bad("timeInterval must be a positive number".into()))?;
     454           20 :             out.insert("timeInterval".into(), v.clone());
     455              :         }
     456              :         // Table 5.2.12-1: all three are Booleans. `localOnly` and
     457              :         // `splitEntities` decide how far the Subscription reaches — a
     458              :         // string read through `as_bool()` is falsy, so accepting one
     459              :         // would turn a subscriber's request for local scope (5.5.13)
     460              :         // into a distributed subscription without telling it.
     461          272 :         "isActive" | "localOnly" | "splitEntities" => {
     462           80 :             if !v.is_boolean() {
     463           32 :                 return Err(bad(format!("{k} must be a boolean (5.2.12)")));
     464           48 :             }
     465           48 :             out.insert(k.to_owned(), v.clone());
     466              :         }
     467          192 :         "temporalQ" => {
     468              :             // 5.2.21 TemporalQuery: timerel and timeAt are cardinality 1
     469              :             // and every member must sit in its Table 5.2.21-1 value
     470              :             // space (used by CSR subscriptions, 5.11.7).
     471           40 :             let tq = v
     472           40 :                 .as_object()
     473           40 :                 .ok_or_else(|| bad("temporalQ must be a TemporalQuery object (5.2.21)".into()))?;
     474           32 :             let mut p = std::collections::HashMap::new();
     475           32 :             crate::paging::temporal_q_params(tq, &mut p)?;
     476           32 :             crate::temporalq::TemporalQ::from_params(&p, true)?;
     477            8 :             out.insert(k.to_owned(), v.clone());
     478              :         }
     479              :         // Table 5.2.12-1: "Valid notification triggers are entityCreated,
     480              :         // entityUpdated, entityDeleted, attributeCreated, attributeUpdated,
     481              :         // attributeDeleted." A trigger outside that set is accepted and
     482              :         // then matches nothing, leaving a subscription that never fires.
     483          152 :         "notificationTrigger" => {
     484           42 :             check_notification_triggers(v)?;
     485           22 :             out.insert(k.to_owned(), v.clone());
     486              :         }
     487              :         // Table 5.2.12-1: csf is "A valid query string as per clause 4.9".
     488              :         // Unparsed here it is stored and only fails at Context Source
     489              :         // Registration matching (5.11.2.4), where it silently matches
     490              :         // nothing instead of telling the subscriber the filter is broken.
     491          110 :         "csf" => {
     492           24 :             let s = v
     493           24 :                 .as_str()
     494           24 :                 .ok_or_else(|| bad("csf must be a query string (5.2.12)".into()))?;
     495           16 :             antares_ql::parse_q(s)?;
     496            8 :             out.insert(k.to_owned(), v.clone());
     497              :         }
     498              :         // Table 5.2.12-1: both are a String, "comma separated list of
     499              :         // attribute names", and the pair decides how the notification
     500              :         // condition compares a value (4.9). A non-string stored here reads
     501              :         // as an absent list at matching time, so the Subscription would
     502              :         // silently compare the wrong thing rather than say the member is
     503              :         // malformed.
     504           86 :         "expandValues" | "jsonKeys" => {
     505           42 :             if !v.is_string() {
     506           32 :                 return Err(bad(format!(
     507           32 :                     "{k} must be a comma separated list of attribute names (5.2.12)"
     508           32 :                 )));
     509           10 :             }
     510           10 :             out.insert(k.to_owned(), v.clone());
     511              :         }
     512              :         // 5.8.6: the @context governing a subscription's notifications is
     513              :         // the @context of the creating request, held in a broker-internal
     514              :         // member. This function only ever sees client input — a create
     515              :         // body or a patch fragment — so dropping the member here stops a
     516              :         // subscriber both from seeding it and from replacing it later.
     517              :         // __via is the same class: the 6.3.18 chain comes from the Via
     518              :         // HTTP header of the creating request, never from the body, and so
     519              :         // is __subject. The prefix as a whole is the broker's, so a member
     520              :         // added later cannot be forgotten here.
     521           44 :         k if k.starts_with("__") => return Ok(()),
     522           24 :         "scopeQ" | "lang" | "subscriptionName" | "name" | "description" | "jsonldContext"
     523           20 :         | "ngsildConformance" | "datasetId" => {
     524            4 :             out.insert(k.to_owned(), v.clone());
     525            4 :         }
     526              :         // tolerant reader: keep unknown members
     527           20 :         _ => {
     528           20 :             out.insert(k.to_owned(), v.clone());
     529           20 :         }
     530              :     }
     531         3116 :     Ok(())
     532         3880 : }
     533              : 
     534              : /// The Table 5.2.12-1 rules that hold between members rather than over
     535              : /// one: what a Subscription must carry, and the pairs it may not.
     536          622 : fn check_subscription_members(out: &Map<String, Value>, is_patch: bool) -> Result<(), NgsiError> {
     537          622 :     let bad = NgsiError::BadRequestData;
     538          622 :     if !is_patch {
     539          578 :         if !out.contains_key("type") {
     540            0 :             return Err(bad("type must be \"Subscription\" (5.2.12)".into()));
     541          578 :         }
     542              :         // 5.2.12: "At least one of (a) entities or (b) watchedAttributes
     543              :         // shall be present, unless the member localOnly is set to true"
     544              :         // (local scope, 5.5.13).
     545          578 :         let local_only = out.get("localOnly").and_then(Value::as_bool) == Some(true);
     546          578 :         if !local_only && !out.contains_key("entities") && !out.contains_key("watchedAttributes") {
     547           12 :             return Err(bad(
     548           12 :                 "one of entities or watchedAttributes is required (5.2.12)".into(),
     549           12 :             ));
     550          566 :         }
     551          566 :         if !out.contains_key("notification") {
     552            4 :             return Err(bad("notification is required (5.2.12)".into()));
     553          562 :         }
     554           44 :     }
     555          606 :     if out.contains_key("timeInterval") && out.contains_key("watchedAttributes") {
     556            8 :         return Err(bad(
     557            8 :             "timeInterval and watchedAttributes are mutually exclusive (5.2.12)".into(),
     558            8 :         ));
     559          598 :     }
     560          598 :     if out.contains_key("timeInterval") && out.contains_key("throttling") {
     561            4 :         return Err(bad(
     562            4 :             "timeInterval and throttling are mutually exclusive (5.2.12)".into(),
     563            4 :         ));
     564          594 :     }
     565          594 :     Ok(())
     566          622 : }
     567              : 
     568              : /// Validate + normalize a subscription document (5.8.1). Types/attribute
     569              : /// names are expanded to IRIs; the rest is stored verbatim.
     570         1348 : pub fn normalize_subscription(
     571         1348 :     doc: &Map<String, Value>,
     572         1348 :     ctx: &Context,
     573         1348 :     is_patch: bool,
     574         1348 : ) -> Result<Map<String, Value>, NgsiError> {
     575         1348 :     let bad = NgsiError::BadRequestData;
     576              :     // 5.5.4: first-level member nulls are only legal in fragments (patch)
     577         1348 :     if !is_patch {
     578         1288 :         antares_jsonld::reject_first_level_nulls(doc)?;
     579           60 :     }
     580         1344 :     let mut out = Map::new();
     581         3892 :     for (k, v) in doc {
     582              :         // 5.4 Fragment member removal: null and the NGSI-LD Null both delete
     583              :         // the member. Read literally, the NGSI-LD Null was stored as the
     584              :         // string it is spelled with, so the member survived carrying it.
     585         3892 :         if is_patch && k != "id" && (v.is_null() || v.as_str() == Some("urn:ngsi-ld:null")) {
     586           12 :             if ["type", "notification"].contains(&k.as_str()) {
     587            8 :                 return Err(bad(format!("cannot remove mandatory member {k} (5.8.3)")));
     588            4 :             }
     589            4 :             out.insert(k.clone(), Value::Null);
     590            4 :             continue;
     591         3880 :         }
     592         3880 :         norm_member(k, v, ctx, &mut out)?;
     593              :     }
     594          622 :     check_subscription_members(&out, is_patch)?;
     595          594 :     Ok(out)
     596         1348 : }
     597              : 
     598              : /// Output shaping: compact IRIs, add status (5.8.3).
     599           98 : pub fn present_subscription(doc: &Value, ctx: &Context, sys_attrs: bool, csource: bool) -> Value {
     600           98 :     let Some(obj) = doc.as_object() else {
     601            0 :         return doc.clone();
     602              :     };
     603           98 :     let mut out = Map::new();
     604          664 :     for (k, v) in obj {
     605          664 :         match k.as_str() {
     606              :             // 5.8.3/5.8.4 serve the 5.2.12 data type, which has no member
     607              :             // under this prefix — see policy::SUBJECT_MEMBER
     608          664 :             k if k.starts_with("__") => continue,
     609          584 :             "createdAt" | "modifiedAt" if !sys_attrs => continue,
     610          468 :             "entities" => {
     611           98 :                 let entities: Vec<Value> = v
     612           98 :                     .as_array()
     613           98 :                     .cloned()
     614           98 :                     .unwrap_or_default()
     615           98 :                     .iter()
     616           98 :                     .map(|e| {
     617           98 :                         let mut ne = e.as_object().cloned().unwrap_or_default();
     618           98 :                         if let Some(t) = ne.get("type").and_then(Value::as_str) {
     619           98 :                             ne.insert("type".into(), Value::String(ctx.compact_iri(t)));
     620           98 :                         }
     621           98 :                         Value::Object(ne)
     622           98 :                     })
     623           98 :                     .collect();
     624           98 :                 out.insert("entities".into(), Value::Array(entities));
     625              :             }
     626          370 :             "watchedAttributes" => {
     627           24 :                 let attrs: Vec<Value> = v
     628           24 :                     .as_array()
     629           24 :                     .cloned()
     630           24 :                     .unwrap_or_default()
     631           24 :                     .iter()
     632           24 :                     .filter_map(Value::as_str)
     633           24 :                     .map(|a| Value::String(ctx.compact_iri(a)))
     634           24 :                     .collect();
     635           24 :                 out.insert("watchedAttributes".into(), Value::Array(attrs));
     636              :             }
     637          346 :             "notification" => {
     638           98 :                 let mut n = v.as_object().cloned().unwrap_or_default();
     639           98 :                 if let Some(attrs) = n.get("attributes").and_then(Value::as_array) {
     640           24 :                     let na: Vec<Value> = attrs
     641           24 :                         .iter()
     642           24 :                         .filter_map(Value::as_str)
     643           24 :                         .map(|a| Value::String(ctx.compact_iri(a)))
     644           24 :                         .collect();
     645           24 :                     n.insert("attributes".into(), Value::Array(na));
     646           74 :                 }
     647           98 :                 out.insert("notification".into(), Value::Object(n));
     648              :             }
     649          248 :             "geoQ" => {
     650           24 :                 let mut g = v.as_object().cloned().unwrap_or_default();
     651           24 :                 if let Some(gp) = g.get("geoproperty").and_then(Value::as_str) {
     652           24 :                     g.insert("geoproperty".into(), Value::String(ctx.compact_iri(gp)));
     653           24 :                 }
     654           24 :                 out.insert("geoQ".into(), Value::Object(g));
     655              :             }
     656          252 :             _ => {
     657          252 :                 out.insert(k.clone(), v.clone());
     658          252 :             }
     659              :         }
     660              :     }
     661              :     // default notificationTrigger surfaced on output (5.2.12; 028_06) —
     662              :     // entity subscriptions only, csource subs have no such default (5.11)
     663           98 :     if !csource && !out.contains_key("notificationTrigger") && !out.contains_key("timeInterval") {
     664           82 :         out.insert(
     665           82 :             "notificationTrigger".into(),
     666           82 :             serde_json::json!(["attributeCreated", "attributeUpdated"]),
     667           82 :         );
     668           82 :     }
     669              :     // status (5.2.12 output): active | paused | expired
     670           98 :     let expired = obj
     671           98 :         .get("expiresAt")
     672           98 :         .and_then(Value::as_str)
     673           98 :         .is_some_and(|e| antares_model::dt_key(e) < antares_model::dt_key(&now_iso()));
     674           98 :     let paused = obj.get("isActive") == Some(&Value::Bool(false));
     675           98 :     let status = if expired {
     676            8 :         "expired"
     677           90 :     } else if paused {
     678            4 :         "paused"
     679           86 :     } else if obj.get("status").and_then(Value::as_str) == Some("failed") {
     680            0 :         "failed" // 5.8.6 / 5.11.7 delivery-failure status
     681              :     } else {
     682           86 :         "active"
     683              :     };
     684           98 :     out.insert("status".into(), Value::String(status.into()));
     685           98 :     Value::Object(out)
     686           98 : }
     687              : 
     688              : // ---------- handlers (parameterized by Kind) ----------
     689              : 
     690              : /// Validate a subscription's jsonldContext member (5.2.12): must be a
     691              : /// dereferenceable @context — invalid value ⇒ 400, unresolvable ⇒ 504.
     692              : /// 5.8.1.4 and 5.8.2.4: the notification endpoint has to be one this
     693              : /// deployment can deliver to. The sink registered for the URI's scheme
     694              : /// (6.3.8, and 7.2 for the optional MQTT binding) validates the endpoint's
     695              : /// own syntax and its `notifierInfo`; a scheme no sink serves is input data
     696              : /// that does not meet the requirements of the operation — BadRequestData,
     697              : /// never a fall-through to the HTTP binding. A fragment that carries no
     698              : /// endpoint leaves the stored one in place and has nothing to check.
     699          354 : fn check_endpoint(st: &AppState, norm: &Map<String, Value>) -> Result<(), NgsiError> {
     700          354 :     let Some(ep) = norm
     701          354 :         .get("notification")
     702          354 :         .and_then(|n| n.get("endpoint"))
     703          354 :         .and_then(Value::as_object)
     704              :     else {
     705           20 :         return Ok(());
     706              :     };
     707          334 :     let Some(uri) = ep.get("uri").and_then(Value::as_str) else {
     708            0 :         return Ok(());
     709              :     };
     710          334 :     let notifier_info = ep
     711          334 :         .get("notifierInfo")
     712          334 :         .and_then(Value::as_array)
     713          334 :         .map(|ni| {
     714            4 :             ni.iter()
     715            4 :                 .filter_map(|kv| Some((kv.get("key")?.as_str()?, kv.get("value")?.as_str()?)))
     716            4 :                 .collect::<Vec<_>>()
     717            4 :         })
     718          334 :         .unwrap_or_default();
     719          334 :     st.sinks.require(uri, &notifier_info)
     720          354 : }
     721              : 
     722          348 : async fn check_jsonld_context(
     723          348 :     st: &AppState,
     724          348 :     tenant: &TenantId,
     725          348 :     norm: &Map<String, Value>,
     726          348 : ) -> Result<(), ApiError> {
     727          348 :     let Some(v) = norm.get("jsonldContext") else {
     728          344 :         return Ok(());
     729              :     };
     730            4 :     let is_url = |s: &str| s.starts_with("http://") || s.starts_with("https://");
     731            4 :     let ok_shape = match v {
     732            4 :         Value::String(s) => is_url(s),
     733            0 :         Value::Array(a) => a.iter().all(|e| e.as_str().is_some_and(is_url)),
     734            0 :         _ => false,
     735              :     };
     736            4 :     if !ok_shape {
     737            0 :         return Err(NgsiError::BadRequestData(format!(
     738            0 :             "jsonldContext is not a valid @context reference: {v}"
     739            0 :         ))
     740            0 :         .into());
     741            4 :     }
     742            4 :     st.loader.resolve_for(tenant, v).await?;
     743            2 :     Ok(())
     744          348 : }
     745              : 
     746              : /// One function serves both subscription resources, and they are different
     747              : /// clauses: 5.8 for Subscriptions, 5.11 for Context Source Registration
     748              : /// Subscriptions. The seam is asked about the operation the caller actually
     749              : /// made.
     750           88 : fn clause_of(kind: Kind, sub: &'static str, csub: &'static str) -> &'static str {
     751           88 :     if kind == Kind::CSourceSubscription {
     752           28 :         csub
     753              :     } else {
     754           60 :         sub
     755              :     }
     756           88 : }
     757              : 
     758          940 : pub async fn create(
     759          940 :     st: &AppState,
     760          940 :     kind: Kind,
     761          940 :     params: &HashMap<String, String>,
     762          940 :     headers: &HeaderMap,
     763          940 :     body: &[u8],
     764          940 : ) -> ApiResult<Response> {
     765          940 :     let tenant = tenant_from(headers)?;
     766          940 :     check_params(params, &["local"])?;
     767          936 :     let parsed = parse_body(&st.loader, headers, body, BodyKind::Standard).await?;
     768          492 :     let obj = parsed.object(NgsiError::BadRequestData(
     769          492 :         "subscription must be a JSON object".into(),
     770          492 :     ))?;
     771              :     // ADR-0020: the client may choose the id (5.8.1.4 / 5.11.2.4), and it is
     772              :     // in hand here — an engine that owns a segment of the id space has to be
     773              :     // told which one is being claimed. A body that names none leaves the
     774              :     // list empty, which is right: there is no client choice to decide about.
     775          492 :     let named = obj.get("id").and_then(Value::as_str);
     776          492 :     gate!(
     777              :         st, &tenant, headers, clause_of(kind, "5.8.1", "5.11.2"),
     778              :         ids: named.as_slice(),
     779              :     )
     780          492 :     .await?;
     781          492 :     let mut norm = normalize_subscription(obj, &parsed.ctx, false)?;
     782          326 :     check_endpoint(st, &norm)?;
     783          320 :     check_jsonld_context(st, &tenant, &norm).await?;
     784          318 :     let id = match norm.get("id").and_then(Value::as_str) {
     785          242 :         Some(id) => id.to_owned(),
     786              :         None => {
     787           76 :             let id = format!("urn:ngsi-ld:Subscription:{}", uuid::Uuid::new_v4());
     788           76 :             norm.insert("id".into(), Value::String(id.clone()));
     789           76 :             id
     790              :         }
     791              :     };
     792              :     // The Registration Subscriptions the distributed half owns (5.8.1.4)
     793              :     // are stored outside the client kinds, under this id namespace: a
     794              :     // client document claiming it would be looked up in that store on the
     795              :     // notification path and would silently never notify.
     796          318 :     if id.starts_with(crate::registry::INTERNAL_CSR_PREFIX) {
     797            0 :         return Err(NgsiError::BadRequestData(format!(
     798            0 :             "subscription id {id} is reserved by the broker"
     799            0 :         ))
     800            0 :         .into());
     801          318 :     }
     802          318 :     let ts = now_iso();
     803          318 :     norm.insert("createdAt".into(), Value::String(ts.clone()));
     804          318 :     norm.insert("modifiedAt".into(), Value::String(ts.clone()));
     805              :     // notification @context = the creating request's context (5.8.6),
     806              :     // stored as its own column — internal member, stripped on output.
     807          318 :     norm.insert("__context".into(), parsed.ctx.source.clone());
     808              :     // 6.3.17/6.3.18: a Subscription arriving as a forwarded copy (5.8.1.4)
     809              :     // carries the Via chain of the brokers it has passed through — kept on
     810              :     // the stored document so the distributed half can extend the chain
     811              :     // outbound and refuse to re-forward a copy that has looped back.
     812          318 :     if let Some(via) = crate::federation::inbound_via(headers) {
     813           16 :         norm.insert("__via".into(), Value::String(via));
     814          302 :     }
     815              :     // ADR-0020: 5.8.6 delivery is broker-initiated, so there is no request
     816              :     // to read a subject off when the notification is sent. The subject is
     817              :     // the subscriber's, taken from the creating request and kept the way
     818              :     // the notification @context is — internal member, never rendered, never
     819              :     // forwarded, never settable by a client.
     820           20 :     if let Some(subject) =
     821          318 :         crate::policy::subject_member(&crate::policy::subject_of(&tenant, headers))
     822           20 :     {
     823           20 :         norm.insert(crate::policy::SUBJECT_MEMBER.into(), subject);
     824          298 :     }
     825              :     // Array @context (>1 entry): the broker must host it at its own URL as an
     826              :     // ImplicitlyCreated @context, surfaced via jsonldContext (5.13.1, 050_03)
     827          318 :     if !norm.contains_key("jsonldContext") {
     828          316 :         if let Value::Array(a) = &parsed.ctx.source {
     829            2 :             if a.len() > 1 {
     830            2 :                 let local_id = uuid::Uuid::new_v4().to_string();
     831            2 :                 let url = format!("{}/{local_id}", crate::contexts::base_url(headers));
     832            2 :                 st.store
     833            2 :                     .context_put(
     834            2 :                         Some(&tenant),
     835            2 :                         &local_id,
     836            2 :                         serde_json::json!({
     837            2 :                             "url": url,
     838            2 :                             "localId": local_id,
     839            2 :                             "kind": "ImplicitlyCreated",
     840            2 :                             "createdAt": ts,
     841            2 :                             // owned by the tenant whose subscription created it
     842            2 :                             "owner": tenant.as_str(),
     843            2 :                             "body": {"@context": parsed.ctx.source.clone()},
     844            2 :                         }),
     845            2 :                     )
     846            2 :                     .await?;
     847            2 :                 st.loader
     848            2 :                     .put_local_for(&tenant, url.clone(), parsed.ctx.source.clone())
     849            2 :                     .await;
     850            2 :                 norm.insert("jsonldContext".into(), Value::String(url));
     851            0 :             }
     852          314 :         }
     853            2 :     }
     854          318 :     let doc = Value::Object(norm);
     855          318 :     if !st.store.create(&tenant, kind, &id, doc.clone()).await? {
     856            0 :         return Err(NgsiError::AlreadyExists(format!("subscription {id} already exists")).into());
     857          318 :     }
     858          318 :     st.sub_changed(&tenant, kind, &id, Some(&doc));
     859          318 :     if kind == Kind::Subscription {
     860              :         // 5.8.1.4: a distributed Subscription creates its internal Context
     861              :         // Source Registration Subscription (consumer half)
     862          308 :         crate::distsub::on_subscription_created(st, &tenant, &doc).await;
     863           10 :     }
     864          318 :     if kind == Kind::CSourceSubscription {
     865              :         // initial CSourceNotification with all matching registrations (5.11.2.4)
     866           10 :         let (st2, t2, id2) = (st.clone(), tenant.clone(), id.clone());
     867           10 :         crate::spawn(async move {
     868           10 :             crate::notify::csource_initial(&st2, &t2, &id2).await;
     869           10 :         });
     870          308 :     }
     871          318 :     Ok(created(
     872          318 :         format!("/ngsi-ld/v1/{}/{id}", resource_path(kind)),
     873          318 :         &tenant,
     874          318 :     ))
     875          940 : }
     876              : 
     877              : /// 5.8.3 Retrieve Subscription: invalid URI 400, unknown id 404, else the
     878              : /// 5.2.12 subscription document (status/timesSent are output members).
     879          122 : pub async fn retrieve(
     880          122 :     st: &AppState,
     881          122 :     kind: Kind,
     882          122 :     id: &str,
     883          122 :     params: &HashMap<String, String>,
     884          122 :     headers: &HeaderMap,
     885          122 : ) -> ApiResult<Response> {
     886          122 :     let tenant = tenant_from(headers)?;
     887          122 :     antares_model::EntityId::new(id)
     888          122 :         .map_err(|_| NgsiError::BadRequestData(format!("invalid subscription id {id:?}")))?;
     889          112 :     check_params(params, &["options", "format", "sysAttrs", "local"])?;
     890          112 :     let accept = parse_accept(headers)?;
     891          104 :     let ctx = request_context(&st.loader, headers).await?;
     892          100 :     gate!(st, &tenant, headers, clause_of(kind, "5.8.3", "5.11.4"), ids: &[id]).await?;
     893          100 :     let doc = st
     894          100 :         .store
     895          100 :         .get(&tenant, kind, id)
     896          100 :         .await?
     897          100 :         .ok_or_else(|| NgsiError::ResourceNotFound(format!("subscription {id} not found")))?;
     898           24 :     let sys = sys_attrs_asked(params);
     899           24 :     let payload = present_subscription(&doc, &ctx, sys, kind == Kind::CSourceSubscription);
     900           24 :     Ok(respond(StatusCode::OK, payload, &ctx, accept, &tenant))
     901          122 : }
     902              : 
     903              : /// 5.8.4 Query Subscriptions: list with 5.5.9 pagination; each element a
     904              : /// 5.2.12 subscription document.
     905          140 : pub async fn list(
     906          140 :     st: &AppState,
     907          140 :     kind: Kind,
     908          140 :     params: &HashMap<String, String>,
     909          140 :     headers: &HeaderMap,
     910          140 : ) -> ApiResult<Response> {
     911          140 :     let tenant = tenant_from(headers)?;
     912          140 :     check_params(
     913          140 :         params,
     914          140 :         &["limit", "offset", "count", "options", "format", "local"],
     915            4 :     )?;
     916          136 :     let accept = parse_accept(headers)?;
     917          132 :     let ctx = request_context(&st.loader, headers).await?;
     918          132 :     gate!(st, &tenant, headers, clause_of(kind, "5.8.4", "5.11.5")).await?;
     919              :     // 5.5.9.1: "only up to a maximum of L NGSI-LD Elements are RETRIEVED
     920              :     // and returned". 5.8.4 takes no filter parameters — `check_params`
     921              :     // above is the whole list — so the tenant IS the match set and the
     922              :     // window is the store's to apply. Reading the tenant and slicing it
     923              :     // here made a tenant at the document ceiling unable to list at all,
     924              :     // because that read is the one carrying the ceiling for client queries.
     925          132 :     let (offset, limit, _) = crate::paging::page_params(st, params)?;
     926          120 :     let (page_docs, total) = st.store.list_slice(&tenant, kind, offset, limit).await?;
     927          120 :     let (page, count_hdr, links) = crate::paging::paginate_pre_accept(
     928          120 :         st,
     929          120 :         params,
     930          120 :         page_docs,
     931          120 :         &format!("/ngsi-ld/v1/{}", resource_path(kind)),
     932          120 :         accept,
     933          120 :         total,
     934            0 :     )?;
     935          120 :     let sys = sys_attrs_asked(params);
     936          120 :     let payload: Vec<Value> = page
     937          120 :         .iter()
     938          120 :         .map(|d| present_subscription(d, &ctx, sys, kind == Kind::CSourceSubscription))
     939          120 :         .collect();
     940          120 :     let mut resp = crate::negotiate::respond_list(StatusCode::OK, payload, &ctx, accept, &tenant);
     941          120 :     attach_paging(&mut resp, count_hdr, &links);
     942          120 :     Ok(resp)
     943          140 : }
     944              : 
     945              : /// 5.8.2 Update Subscription: invalid URI 400, unknown id 404, fragment
     946              : /// validated per 5.5.4 + 5.2.12 (past expiresAt 400), jsonldContext
     947              : /// unavailable -> LdContextNotAvailable / invalid -> 400, modify per 5.5.8;
     948              : /// the 5.8.2.4 status table falls out of the computed status
     949              : /// (isActive/expiresAt) on read.
     950           48 : pub async fn update(
     951           48 :     st: &AppState,
     952           48 :     kind: Kind,
     953           48 :     id: &str,
     954           48 :     params: &HashMap<String, String>,
     955           48 :     headers: &HeaderMap,
     956           48 :     body: &[u8],
     957           48 : ) -> ApiResult<Response> {
     958           48 :     let tenant = tenant_from(headers)?;
     959           48 :     antares_model::EntityId::new(id)
     960           48 :         .map_err(|_| NgsiError::BadRequestData(format!("invalid subscription id {id:?}")))?;
     961           40 :     check_params(params, &["local"])?;
     962           40 :     gate!(st, &tenant, headers, clause_of(kind, "5.8.2", "5.11.3"), ids: &[id]).await?;
     963           40 :     let parsed = parse_body(&st.loader, headers, body, BodyKind::MergePatch).await?;
     964           28 :     let obj = parsed.object(NgsiError::BadRequestData(
     965           28 :         "fragment must be a JSON object".into(),
     966           28 :     ))?;
     967           28 :     if let Some(bid) = obj.get("id").and_then(Value::as_str) {
     968            0 :         if bid != id {
     969            0 :             return Err(NgsiError::BadRequestData("fragment id mismatch".into()).into());
     970            0 :         }
     971           28 :     }
     972           28 :     let norm = normalize_subscription(obj, &parsed.ctx, true)?;
     973           28 :     check_endpoint(st, &norm)?;
     974           28 :     check_jsonld_context(st, &tenant, &norm).await?;
     975           28 :     let ts = now_iso();
     976           28 :     let res = st
     977           28 :         .store
     978           28 :         .mutate(&tenant, kind, id, |doc| {
     979            4 :             let target = antares_store::stored_object(doc)?;
     980            4 :             crate::apply_doc_fragment(target, &norm, &ts);
     981            4 :             Ok::<(), NgsiError>(())
     982            4 :         })
     983           28 :         .await?;
     984            4 :     match res {
     985           24 :         None => Err(NgsiError::ResourceNotFound(format!("subscription {id} not found")).into()),
     986            0 :         Some(Err(e)) => Err(e.into()),
     987              :         Some(Ok(())) => {
     988            4 :             if kind == Kind::CSourceSubscription {
     989              :                 // 5.11.3.4: after update, notify with all currently matching
     990            0 :                 let (st2, t2, id2) = (st.clone(), tenant.clone(), id.to_owned());
     991            0 :                 crate::spawn(async move {
     992            0 :                     crate::notify::csource_initial(&st2, &t2, &id2).await;
     993            0 :                 });
     994            4 :             }
     995            4 :             if st.sub_sync.is_some() {
     996            4 :                 let doc = st.store.get(&tenant, kind, id).await?;
     997            4 :                 st.sub_changed(&tenant, kind, id, doc.as_ref());
     998            0 :             }
     999            4 :             if kind == Kind::Subscription {
    1000              :                 // 5.8.2.4: the CSR subscription and the mapped remote
    1001              :                 // subscriptions follow the update (5.11.3)
    1002            4 :                 crate::distsub::on_subscription_updated(st, &tenant, id).await;
    1003            0 :             }
    1004            4 :             Ok(no_content(&tenant))
    1005              :         }
    1006              :     }
    1007           48 : }
    1008              : 
    1009              : /// 5.8.5 Delete Subscription: invalid URI 400, unknown id 404, 204 on
    1010              : /// success and no further notifications (sub_changed drops the mirror).
    1011          266 : pub async fn delete(
    1012          266 :     st: &AppState,
    1013          266 :     kind: Kind,
    1014          266 :     id: &str,
    1015          266 :     params: &HashMap<String, String>,
    1016          266 :     headers: &HeaderMap,
    1017          266 : ) -> ApiResult<Response> {
    1018          266 :     let tenant = tenant_from(headers)?;
    1019          266 :     antares_model::EntityId::new(id)
    1020          266 :         .map_err(|_| NgsiError::BadRequestData(format!("invalid subscription id {id:?}")))?;
    1021          258 :     check_params(params, &["local"])?;
    1022          258 :     gate!(st, &tenant, headers, clause_of(kind, "5.8.5", "5.11.6"), ids: &[id]).await?;
    1023          258 :     if st.store.delete(&tenant, kind, id).await? {
    1024           46 :         st.sub_changed(&tenant, kind, id, None);
    1025           46 :         if kind == Kind::Subscription {
    1026              :             // 5.8.5.4: forward the delete to every mapped Context Source
    1027              :             // and drop the internal CSR subscription (5.11.6)
    1028           46 :             crate::distsub::on_subscription_deleted(st, &tenant, id).await;
    1029            0 :         }
    1030           46 :         Ok(no_content(&tenant))
    1031              :     } else {
    1032          212 :         Err(NgsiError::ResourceNotFound(format!("subscription {id} not found")).into())
    1033              :     }
    1034          266 : }
    1035              : 
    1036              : // axum route fns
    1037              : 
    1038              : macro_rules! route4 {
    1039              :     ($create:ident, $retrieve:ident, $list:ident, $update:ident, $delete:ident, $kind:expr, $c:literal) => {
    1040              :         #[doc = concat!("HTTP handler for ", $c, " Create: the axum seam over [`create`].")]
    1041          940 :         pub async fn $create(
    1042          940 :             State(st): State<AppState>,
    1043          940 :             CleanParams(params): CleanParams,
    1044          940 :             headers: HeaderMap,
    1045          940 :             body: Bytes,
    1046          940 :         ) -> Response {
    1047          940 :             create(&st, $kind, &params, &headers, &body)
    1048          940 :                 .await
    1049          940 :                 .unwrap_or_else(|e| e.into_response())
    1050          940 :         }
    1051              :         #[doc = concat!("HTTP handler for ", $c, " Retrieve: the axum seam over [`retrieve`].")]
    1052          122 :         pub async fn $retrieve(
    1053          122 :             State(st): State<AppState>,
    1054          122 :             Path(id): Path<String>,
    1055          122 :             CleanParams(params): CleanParams,
    1056          122 :             headers: HeaderMap,
    1057          122 :         ) -> Response {
    1058          122 :             retrieve(&st, $kind, &id, &params, &headers)
    1059          122 :                 .await
    1060          122 :                 .unwrap_or_else(|e| e.into_response())
    1061          122 :         }
    1062              :         #[doc = concat!("HTTP handler for ", $c, " Query: the axum seam over [`list`].")]
    1063          140 :         pub async fn $list(
    1064          140 :             State(st): State<AppState>,
    1065          140 :             CleanParams(params): CleanParams,
    1066          140 :             headers: HeaderMap,
    1067          140 :         ) -> Response {
    1068          140 :             list(&st, $kind, &params, &headers)
    1069          140 :                 .await
    1070          140 :                 .unwrap_or_else(|e| e.into_response())
    1071          140 :         }
    1072              :         #[doc = concat!("HTTP handler for ", $c, " Update: the axum seam over [`update`].")]
    1073           48 :         pub async fn $update(
    1074           48 :             State(st): State<AppState>,
    1075           48 :             Path(id): Path<String>,
    1076           48 :             CleanParams(params): CleanParams,
    1077           48 :             headers: HeaderMap,
    1078           48 :             body: Bytes,
    1079           48 :         ) -> Response {
    1080           48 :             update(&st, $kind, &id, &params, &headers, &body)
    1081           48 :                 .await
    1082           48 :                 .unwrap_or_else(|e| e.into_response())
    1083           48 :         }
    1084              :         #[doc = concat!("HTTP handler for ", $c, " Delete: the axum seam over [`delete`].")]
    1085          266 :         pub async fn $delete(
    1086          266 :             State(st): State<AppState>,
    1087          266 :             Path(id): Path<String>,
    1088          266 :             CleanParams(params): CleanParams,
    1089          266 :             headers: HeaderMap,
    1090          266 :         ) -> Response {
    1091          266 :             delete(&st, $kind, &id, &params, &headers)
    1092          266 :                 .await
    1093          266 :                 .unwrap_or_else(|e| e.into_response())
    1094          266 :         }
    1095              :     };
    1096              : }
    1097              : 
    1098              : route4!(
    1099              :     create_subscription,
    1100              :     retrieve_subscription,
    1101              :     query_subscriptions,
    1102              :     update_subscription,
    1103              :     delete_subscription,
    1104              :     Kind::Subscription,
    1105              :     "5.8 Subscription"
    1106              : );
    1107              : route4!(
    1108              :     create_csource_subscription,
    1109              :     retrieve_csource_subscription,
    1110              :     query_csource_subscriptions,
    1111              :     update_csource_subscription,
    1112              :     delete_csource_subscription,
    1113              :     Kind::CSourceSubscription,
    1114              :     "5.11 Context Source Registration Subscription"
    1115              : );
    1116              : 
    1117              : #[cfg(test)]
    1118              : mod tests {
    1119              :     use super::*;
    1120              :     use antares_jsonld::Loader;
    1121              :     use serde_json::json;
    1122              : 
    1123              :     /// Table 5.2.33-1 `type`: "To indicate a request for all Entities (with
    1124              :     /// implied local scope), \"*\" is also allowed as a value." It is neither
    1125              :     /// a term nor a 4.17 expression: expanding it yields an IRI no entity
    1126              :     /// carries, so the subscription is created and then silently notifies
    1127              :     /// nothing. The stored form has to survive normalization for the matcher
    1128              :     /// to read it.
    1129              :     #[test]
    1130            4 :     fn clause_5_2_33_a_star_selector_survives_normalization_and_matches() {
    1131            4 :         let ctx = Loader::new().core();
    1132            4 :         let doc = json!({
    1133            4 :             "id": "urn:ngsi-ld:Subscription:star",
    1134            4 :             "type": "Subscription",
    1135            4 :             "entities": [{"type": "*"}],
    1136            4 :             "notification": {"endpoint": {"uri": "http://localhost:1/n"}},
    1137              :         });
    1138            4 :         let norm = normalize_subscription(doc.as_object().expect("object"), &ctx, false)
    1139            4 :             .expect("Table 5.2.33-1 allows \"*\"");
    1140            4 :         assert_eq!(
    1141            4 :             norm["entities"][0]["type"],
    1142            4 :             json!("*"),
    1143              :             "\"*\" must stay raw: an expanded term matches no entity"
    1144              :         );
    1145            4 :         let stored = Value::Object(norm);
    1146            4 :         assert!(
    1147            4 :             antares_matcher::selector_match(
    1148            4 :                 &stored,
    1149            4 :                 &json!({"id": "urn:ngsi-ld:Vehicle:1",
    1150            4 :                         "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"]}),
    1151            4 :                 &ctx,
    1152              :             ),
    1153              :             "a stored \"*\" subscription must match every Entity"
    1154              :         );
    1155            4 :     }
    1156              : 
    1157              :     /// 5.5.4: "urn:ngsi-ld:null" as a first-level member value is
    1158              :     /// BadRequestData on create; in a patch fragment it is the removal form.
    1159              :     #[test]
    1160            4 :     fn clause_5_5_4_first_level_null_in_subscription() {
    1161            4 :         let ctx = Loader::new().core();
    1162            4 :         let doc = json!({
    1163            4 :             "type": "Subscription",
    1164            4 :             "entities": [{"type": "Building"}],
    1165            4 :             "description": "urn:ngsi-ld:null",
    1166            4 :             "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
    1167              :         });
    1168            4 :         assert!(
    1169            4 :             normalize_subscription(doc.as_object().unwrap(), &ctx, false).is_err(),
    1170              :             "create with a first-level null URN must be rejected"
    1171              :         );
    1172              :         // patch fragment: 5.4 removal semantics — the member is marked for
    1173              :         // deletion, never stored carrying the string it is spelled with
    1174            4 :         let patch = json!({"description": "urn:ngsi-ld:null"});
    1175            4 :         let n = normalize_subscription(patch.as_object().unwrap(), &ctx, true).expect("fragment");
    1176            4 :         assert_eq!(
    1177            4 :             n["description"],
    1178              :             Value::Null,
    1179              :             "the NGSI-LD Null removes the member, like a JSON null"
    1180              :         );
    1181              :         // and a mandatory member cannot be removed at all (5.8.3)
    1182            8 :         for k in ["type", "notification"] {
    1183            8 :             let patch = json!({ k: "urn:ngsi-ld:null" });
    1184            8 :             assert!(
    1185            8 :                 normalize_subscription(patch.as_object().unwrap(), &ctx, true).is_err(),
    1186              :                 "removing the mandatory member {k} must be refused"
    1187              :             );
    1188              :         }
    1189            4 :     }
    1190              : 
    1191              :     #[test]
    1192            4 :     fn validates_subscription() {
    1193            4 :         let ctx = Loader::new().core();
    1194            4 :         let doc = json!({
    1195            4 :             "id": "urn:ngsi-ld:Subscription:1",
    1196            4 :             "type": "Subscription",
    1197            4 :             "entities": [{"type": "Building"}],
    1198            4 :             "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
    1199              :         });
    1200            4 :         let n = normalize_subscription(doc.as_object().unwrap(), &ctx, false).expect("valid");
    1201            4 :         assert_eq!(
    1202            4 :             n["entities"][0]["type"],
    1203              :             "https://uri.etsi.org/ngsi-ld/default-context/Building"
    1204              :         );
    1205              : 
    1206            4 :         let missing_notification = json!({
    1207            4 :             "type": "Subscription",
    1208            4 :             "entities": [{"type": "Building"}]
    1209              :         });
    1210            4 :         assert!(
    1211            4 :             normalize_subscription(missing_notification.as_object().unwrap(), &ctx, false).is_err()
    1212              :         );
    1213              : 
    1214            4 :         let past_expiry = json!({
    1215            4 :             "type": "Subscription",
    1216            4 :             "entities": [{"type": "Building"}],
    1217            4 :             "expiresAt": "2020-01-01T00:00:00Z",
    1218            4 :             "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
    1219              :         });
    1220            4 :         assert!(normalize_subscription(past_expiry.as_object().unwrap(), &ctx, false).is_err());
    1221            4 :     }
    1222              : 
    1223              :     /// Table 5.2.14.1-1 p.120: "showChanges cannot be true in case format is
    1224              :     /// keyValues". "simplified" is the table's declared synonym.
    1225              :     #[test]
    1226            4 :     fn show_changes_with_key_values_is_rejected() {
    1227            4 :         let ctx = Loader::new().core();
    1228           20 :         let mk = |format: &str, show: bool| {
    1229           20 :             json!({
    1230           20 :                 "type": "Subscription",
    1231           20 :                 "entities": [{"type": "Building"}],
    1232           20 :                 "notification": {
    1233           20 :                     "format": format,
    1234           20 :                     "showChanges": show,
    1235           20 :                     "endpoint": {"uri": "http://localhost:1111/notify"}
    1236              :                 }
    1237              :             })
    1238           20 :         };
    1239            8 :         for f in ["keyValues", "simplified"] {
    1240            8 :             assert!(
    1241            8 :                 normalize_subscription(mk(f, true).as_object().unwrap(), &ctx, false).is_err(),
    1242              :                 "showChanges+{f} must be rejected"
    1243              :             );
    1244            8 :             assert!(
    1245            8 :                 normalize_subscription(mk(f, false).as_object().unwrap(), &ctx, false).is_ok(),
    1246              :                 "{f} without showChanges is fine"
    1247              :             );
    1248              :         }
    1249            4 :         assert!(
    1250            4 :             normalize_subscription(mk("normalized", true).as_object().unwrap(), &ctx, false)
    1251            4 :                 .is_ok(),
    1252              :             "showChanges+normalized is fine"
    1253              :         );
    1254            4 :     }
    1255              : 
    1256              :     /// Table 5.2.14.1-1 p.119: "Empty array (0 length) is not allowed" on
    1257              :     /// notification.attributes / pick / omit.
    1258              :     #[test]
    1259            4 :     fn empty_projection_arrays_are_rejected() {
    1260            4 :         let ctx = Loader::new().core();
    1261           12 :         for key in ["attributes", "pick", "omit"] {
    1262           12 :             let doc = json!({
    1263           12 :                 "type": "Subscription",
    1264           12 :                 "entities": [{"type": "Building"}],
    1265           12 :                 "notification": {
    1266           12 :                     key: [],
    1267           12 :                     "endpoint": {"uri": "http://localhost:1111/notify"}
    1268              :                 }
    1269              :             });
    1270           12 :             assert!(
    1271           12 :                 normalize_subscription(doc.as_object().unwrap(), &ctx, false).is_err(),
    1272              :                 "empty notification.{key} must be rejected"
    1273              :             );
    1274              :         }
    1275            4 :     }
    1276              : 
    1277              :     // ---------- shared fixtures ----------
    1278              : 
    1279              :     /// The minimal valid 5.2.12 Subscription, with `extra` merged over it.
    1280          736 :     fn sub(extra: Value) -> Value {
    1281          736 :         let mut base = json!({
    1282          736 :             "type": "Subscription",
    1283          736 :             "entities": [{"type": "Building"}],
    1284          736 :             "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
    1285              :         });
    1286          736 :         let obj = base.as_object_mut().expect("object");
    1287          760 :         for (k, v) in extra.as_object().expect("object") {
    1288          760 :             obj.insert(k.clone(), v.clone());
    1289          760 :         }
    1290          736 :         base
    1291          736 :     }
    1292              : 
    1293          740 :     fn norm(doc: &Value) -> Result<Map<String, Value>, NgsiError> {
    1294          740 :         normalize_subscription(
    1295          740 :             doc.as_object().expect("object"),
    1296          740 :             &Loader::new().core(),
    1297              :             false,
    1298              :         )
    1299          740 :     }
    1300              : 
    1301            8 :     fn frag(doc: &Value) -> Result<Map<String, Value>, NgsiError> {
    1302            8 :         normalize_subscription(
    1303            8 :             doc.as_object().expect("object"),
    1304            8 :             &Loader::new().core(),
    1305              :             true,
    1306              :         )
    1307            8 :     }
    1308              : 
    1309              :     /// 6.3.8 / 6.3.9: "each of the KeyValuePairs shall be included as a
    1310              :     /// custom HTTP header … 'Key' and 'value' members shall adhere to IETF
    1311              :     /// RFC 7230 clause 3.2 definitions concerning HTTP headers". A pair that
    1312              :     /// cannot be written as a header is refused at create, not at delivery:
    1313              :     /// a subscriber that could smuggle CR or LF into a receiverInfo value
    1314              :     /// would write its own headers — or its own request line — into every
    1315              :     /// notification the broker sends to that endpoint.
    1316              :     #[test]
    1317            4 :     fn clause_6_3_8_receiver_info_that_cannot_be_a_header_is_refused() {
    1318           52 :         let with = |k: &str, v: &str| {
    1319           52 :             sub(json!({"notification": {"endpoint": {
    1320           52 :                 "uri": "http://localhost:1111/notify",
    1321           52 :                 "receiverInfo": [{"key": k, "value": v}],
    1322              :             }}}))
    1323           52 :         };
    1324           44 :         for (k, v, why) in [
    1325            4 :             ("X-Ok", "a\r\nInjected: 1", "a CRLF splits the header block"),
    1326            4 :             (
    1327            4 :                 "X-Ok",
    1328            4 :                 "a\nInjected: 1",
    1329            4 :                 "a bare LF is enough for most parsers",
    1330            4 :             ),
    1331            4 :             ("X-Ok", "a\rInjected: 1", "so is a bare CR"),
    1332            4 :             ("X-Ok", "a\0b", "NUL is not field-content"),
    1333            4 :             ("X-Ok", " leading", "OWS may not be part of the value"),
    1334            4 :             ("X-Ok", "trailing\t", "nor may it trail it"),
    1335            4 :             ("Bad Key", "v", "SP is not a tchar"),
    1336            4 :             ("X:Ok", "v", "neither is the field separator"),
    1337            4 :             ("", "v", "a field-name is one or more tchar"),
    1338            4 :             ("X-Ok\r\nInjected", "v", "the key is a header name too"),
    1339            4 :             ("X-Ünicode", "v", "field-name is ASCII"),
    1340            4 :         ] {
    1341           44 :             assert!(
    1342           44 :                 norm(&with(k, v)).is_err(),
    1343              :                 "receiverInfo {k:?}: {v:?} must be refused — {why}"
    1344              :             );
    1345              :         }
    1346              :         // and the negative: an empty value and the full tchar repertoire are
    1347              :         // legal headers, so they stay legal Subscriptions
    1348            8 :         for (k, v) in [
    1349            4 :             ("X-Empty", ""),
    1350            4 :             ("!#$%&x*+-.^_`|~0Aa", "value with spaces inside"),
    1351            4 :         ] {
    1352            8 :             assert!(norm(&with(k, v)).is_ok(), "receiverInfo {k:?}: {v:?}");
    1353              :         }
    1354            4 :     }
    1355              : 
    1356              :     /// Table 5.2.12-1: `localOnly` and `splitEntities` are Booleans. Both
    1357              :     /// decide how far a Subscription reaches — `localOnly=true` confines it
    1358              :     /// to "the Entities stored locally" (5.5.13), and `splitEntities` decides
    1359              :     /// whether filters may be applied locally at all. A string `"true"` read
    1360              :     /// through `as_bool()` is falsy, so accepting one turns a subscriber's
    1361              :     /// request for local scope into a distributed subscription that forwards
    1362              :     /// to every matching Context Source, and the subscriber is never told.
    1363              :     #[test]
    1364            4 :     fn clause_5_2_12_local_only_and_split_entities_are_booleans() {
    1365            8 :         for member in ["localOnly", "splitEntities"] {
    1366           32 :             for v in [json!("true"), json!(1), json!("false"), json!([true])] {
    1367           32 :                 assert!(
    1368           32 :                     norm(&sub(json!({member: v.clone()}))).is_err(),
    1369              :                     "{member}: {v} is not a Boolean (5.2.12)"
    1370              :                 );
    1371              :             }
    1372           16 :             for v in [json!(true), json!(false)] {
    1373           16 :                 let out = norm(&sub(json!({member: v.clone()}))).expect("a Boolean is accepted");
    1374           16 :                 assert_eq!(out[member], v, "{member} is stored as given");
    1375              :             }
    1376              :         }
    1377            4 :     }
    1378              : 
    1379              :     /// Table 5.2.12-1: `expandValues` and `jsonKeys` are each a String,
    1380              :     /// "comma separated list of attribute names". They decide whether the
    1381              :     /// notification condition compares a term's value as written or after
    1382              :     /// JSON-LD type coercion (4.9), and a non-string reads as an absent
    1383              :     /// list at matching time — the Subscription would then quietly match on
    1384              :     /// the other reading instead of refusing the member.
    1385              :     #[test]
    1386            4 :     fn clause_5_2_12_expand_values_and_json_keys_are_strings() {
    1387            8 :         for member in ["expandValues", "jsonKeys"] {
    1388           32 :             for v in [json!(1), json!(["category"]), json!(true), json!({})] {
    1389           32 :                 assert!(
    1390           32 :                     norm(&sub(json!({member: v.clone()}))).is_err(),
    1391              :                     "{member}: {v} is not a comma separated list (5.2.12)"
    1392              :                 );
    1393              :             }
    1394            8 :             let v = json!("category,brandName");
    1395            8 :             let out = norm(&sub(json!({member: v.clone()}))).expect("a String is accepted");
    1396            8 :             assert_eq!(out[member], v, "{member} is stored as given");
    1397              :         }
    1398            4 :     }
    1399              : 
    1400              :     // ---------- 5.2.12 read-only and internal members ----------
    1401              : 
    1402              :     /// 5.8.6: the @context governing a Subscription's notifications is the
    1403              :     /// @context of the creating request, kept in a broker-internal member.
    1404              :     /// A Context Subscriber can neither set it (create) nor replace it
    1405              :     /// (update), and it appears in no served representation — 5.8.3 and
    1406              :     /// 5.8.4 serve the 5.2.12 data type, which has no such member.
    1407              :     #[test]
    1408            4 :     fn clause_5_8_6_internal_context_member_is_client_proof() {
    1409            4 :         let hostile = json!({"__context": "http://attacker.invalid/ctx.jsonld",
    1410            4 :                              "__via": "1.1 forged-alias"});
    1411            4 :         let created = norm(&sub(hostile.clone())).expect("valid subscription");
    1412            4 :         assert!(
    1413            4 :             !created.contains_key("__context"),
    1414              :             "a client-supplied __context must not reach storage: {created:?}"
    1415              :         );
    1416            4 :         assert!(
    1417            4 :             !created.contains_key("__via"),
    1418              :             "a body member must not forge the Via chain — the chain comes \
    1419              :              from the HTTP header only: {created:?}"
    1420              :         );
    1421            4 :         let patched = frag(&hostile).expect("valid fragment");
    1422            4 :         assert!(
    1423            4 :             !patched.contains_key("__context"),
    1424              :             "a patch fragment must not replace the notification @context: {patched:?}"
    1425              :         );
    1426            4 :         assert!(
    1427            4 :             !patched.contains_key("__via"),
    1428              :             "a patch fragment must not rewrite the stored Via chain: {patched:?}"
    1429              :         );
    1430            4 :         let ctx = Loader::new().core();
    1431            4 :         let stored = json!({
    1432            4 :             "id": "urn:ngsi-ld:Subscription:ctx",
    1433            4 :             "type": "Subscription",
    1434            4 :             "entities": [{"type": "Building"}],
    1435            4 :             "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}},
    1436            4 :             "__context": "https://example.org/private-ctx.jsonld",
    1437            4 :             "__via": "1.1 upstream-broker",
    1438              :         });
    1439            8 :         for csource in [false, true] {
    1440           16 :             for sys in [false, true] {
    1441           16 :                 let out = present_subscription(&stored, &ctx, sys, csource);
    1442           16 :                 assert!(
    1443           16 :                     !out.to_string().contains("__context"),
    1444              :                     "served representation leaked the internal @context member: {out}"
    1445              :                 );
    1446           16 :                 assert!(
    1447           16 :                     !out.to_string().contains("private-ctx"),
    1448              :                     "served representation leaked the internal @context value: {out}"
    1449              :                 );
    1450           16 :                 assert!(
    1451           16 :                     !out.to_string().contains("__via")
    1452           16 :                         && !out.to_string().contains("upstream-broker"),
    1453              :                     "5.8.3/5.8.4 serve the 5.2.12 data type, which has no Via member: {out}"
    1454              :                 );
    1455              :             }
    1456              :         }
    1457            4 :     }
    1458              : 
    1459              :     /// Table 5.2.12-2 and 5.2.14.2: read-only members "shall not be provided
    1460              :     /// by Context Subscribers. In the event that they are provided (in
    1461              :     /// update or create operations) NGSI-LD implementations shall ignore
    1462              :     /// them."
    1463              :     #[test]
    1464            4 :     fn clause_5_2_12_read_only_members_are_ignored() {
    1465            4 :         let doc = sub(json!({
    1466            4 :             "status": "active",
    1467            4 :             "createdAt": "2020-01-01T00:00:00Z",
    1468            4 :             "modifiedAt": "2020-01-01T00:00:00Z",
    1469            4 :             "notification": {
    1470            4 :                 "endpoint": {"uri": "http://localhost:1111/notify"},
    1471            4 :                 "timesSent": 42,
    1472            4 :                 "lastNotification": "2020-01-01T00:00:00Z",
    1473            4 :                 "lastSuccess": "2020-01-01T00:00:00Z",
    1474            4 :                 "lastFailure": "2020-01-01T00:00:00Z",
    1475              :             }
    1476              :         }));
    1477            8 :         for is_patch in [false, true] {
    1478            8 :             let n = normalize_subscription(
    1479            8 :                 doc.as_object().expect("object"),
    1480            8 :                 &Loader::new().core(),
    1481            8 :                 is_patch,
    1482              :             )
    1483            8 :             .expect("valid");
    1484           24 :             for k in ["status", "createdAt", "modifiedAt"] {
    1485           24 :                 assert!(
    1486           24 :                     !n.contains_key(k),
    1487              :                     "{k} must not persist (patch={is_patch})"
    1488              :                 );
    1489              :             }
    1490            8 :             let notif = n["notification"].as_object().expect("notification");
    1491           32 :             for k in [
    1492            8 :                 "timesSent",
    1493            8 :                 "lastNotification",
    1494            8 :                 "lastSuccess",
    1495            8 :                 "lastFailure",
    1496            8 :             ] {
    1497           32 :                 assert!(
    1498           32 :                     !notif.contains_key(k),
    1499              :                     "notification.{k} must not persist (patch={is_patch})"
    1500              :                 );
    1501              :             }
    1502              :         }
    1503            4 :     }
    1504              : 
    1505              :     // ---------- 5.2.12 value spaces ----------
    1506              : 
    1507              :     /// Table 5.2.12-1: "Valid notification triggers are entityCreated,
    1508              :     /// entityUpdated, entityDeleted, attributeCreated, attributeUpdated,
    1509              :     /// attributeDeleted". A trigger outside that set would leave a
    1510              :     /// subscription that silently never fires.
    1511              :     #[test]
    1512            4 :     fn clause_5_2_12_notification_trigger_value_space() {
    1513           12 :         for good in [
    1514            4 :             json!(["entityCreated"]),
    1515            4 :             json!(["entityUpdated", "entityDeleted"]),
    1516            4 :             json!(["attributeCreated", "attributeUpdated", "attributeDeleted"]),
    1517            4 :         ] {
    1518           12 :             assert!(
    1519           12 :                 norm(&sub(json!({ "notificationTrigger": good }))).is_ok(),
    1520              :                 "{good} must be accepted"
    1521              :             );
    1522              :         }
    1523           20 :         for bad in [
    1524            4 :             json!(["entityChanged"]),
    1525            4 :             json!(["attributeCreated", "nope"]),
    1526            4 :             json!("entityCreated"),
    1527            4 :             json!([1]),
    1528            4 :             json!({}),
    1529            4 :         ] {
    1530           20 :             assert!(
    1531           20 :                 norm(&sub(json!({ "notificationTrigger": bad }))).is_err(),
    1532              :                 "{bad} must be rejected"
    1533              :             );
    1534              :         }
    1535            4 :     }
    1536              : 
    1537              :     /// Table 5.2.12-1: csf is "A valid query string as per clause 4.9". An
    1538              :     /// unparseable filter is otherwise stored and only fails at Context
    1539              :     /// Source Registration matching time (5.11.2.4), where it silently
    1540              :     /// matches nothing.
    1541              :     #[test]
    1542            4 :     fn clause_5_2_12_csf_must_be_a_valid_query() {
    1543            4 :         assert!(norm(&sub(json!({"csf": "endpoint==\"http://a/x\""}))).is_ok());
    1544           16 :         for bad in [json!("(("), json!("a==(("), json!(5), json!(["a==1"])] {
    1545           16 :             assert!(
    1546           16 :                 norm(&sub(json!({ "csf": bad }))).is_err(),
    1547              :                 "{bad} must be rejected"
    1548              :             );
    1549              :         }
    1550            4 :     }
    1551              : 
    1552              :     /// Table 5.2.12-1: throttling and timeInterval are Numbers "Greater
    1553              :     /// than 0"; throttling allows fractional values, and neither accepts a
    1554              :     /// stringified number. The table sets no lower bound on `timeInterval`
    1555              :     /// beyond that, so a sub-second one is a legal Subscription and is
    1556              :     /// rejected by nothing.
    1557              :     #[test]
    1558            4 :     fn clause_5_2_12_throttling_and_time_interval_value_space() {
    1559            4 :         assert!(norm(&sub(json!({"throttling": 0.5}))).is_ok());
    1560            4 :         assert!(norm(&sub(json!({"timeInterval": 5}))).is_ok());
    1561            4 :         assert!(norm(&sub(json!({"timeInterval": 0.5}))).is_ok());
    1562           20 :         for bad in [json!(0), json!(-1), json!("5"), json!(true), json!(null)] {
    1563           20 :             assert!(
    1564           20 :                 norm(&sub(json!({ "throttling": bad }))).is_err(),
    1565              :                 "throttling {bad} must be rejected"
    1566              :             );
    1567           20 :             assert!(
    1568           20 :                 norm(&sub(json!({ "timeInterval": bad }))).is_err(),
    1569              :                 "timeInterval {bad} must be rejected"
    1570              :             );
    1571              :         }
    1572            4 :     }
    1573              : 
    1574              :     /// Table 5.2.12-1: expiresAt is a 4.6.3 DateTime, and 5.8.1.4/5.8.2.4
    1575              :     /// reject one "referring to a DateTime in the past". 4.6.3 admits both
    1576              :     /// fraction separators, so the past/future decision must be taken on the
    1577              :     /// instant, not on the raw string.
    1578              :     #[test]
    1579            4 :     fn clause_5_2_12_expires_at_boundary() {
    1580            4 :         assert!(norm(&sub(json!({"expiresAt": "2099-01-01T00:00:00Z"}))).is_ok());
    1581              :         // comma is the other legal fraction separator (4.6.3); comparing the
    1582              :         // raw strings would place ',' before now_iso()'s '.' and reject it
    1583            4 :         assert!(
    1584            4 :             norm(&sub(json!({"expiresAt": "2099-01-01T00:00:00,500Z"}))).is_ok(),
    1585              :             "a comma fraction separator is legal (4.6.3)"
    1586              :         );
    1587            4 :         let soon = (chrono::Utc::now() + chrono::Duration::seconds(30))
    1588            4 :             .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
    1589            4 :         assert!(
    1590            4 :             norm(&sub(json!({ "expiresAt": soon }))).is_ok(),
    1591              :             "a whole-second DateTime 30s ahead is in the future: {soon}"
    1592              :         );
    1593           20 :         for bad in [
    1594            4 :             json!("2020-01-01T00:00:00Z"),
    1595            4 :             json!("tomorrow"),
    1596            4 :             json!("2099-01-01"),
    1597            4 :             json!("2099-01-01T00:00:00+05:00"),
    1598            4 :             json!(1234),
    1599            4 :         ] {
    1600           20 :             assert!(
    1601           20 :                 norm(&sub(json!({ "expiresAt": bad }))).is_err(),
    1602              :                 "expiresAt {bad} must be rejected"
    1603              :             );
    1604              :         }
    1605            4 :     }
    1606              : 
    1607              :     /// Table 5.2.12-1: watchedAttributes is a String[] of Attribute names,
    1608              :     /// "Empty array (0 length) is not allowed"; names expand per 5.5.7.
    1609              :     #[test]
    1610            4 :     fn clause_5_2_12_watched_attributes_contract() {
    1611            4 :         let n = norm(&sub(json!({"watchedAttributes": ["temperature"]}))).expect("valid");
    1612            4 :         assert_eq!(
    1613            4 :             n["watchedAttributes"][0],
    1614              :             "https://uri.etsi.org/ngsi-ld/default-context/temperature"
    1615              :         );
    1616           16 :         for bad in [json!([]), json!("temperature"), json!([1]), json!([""])] {
    1617           16 :             assert!(
    1618           16 :                 norm(&sub(json!({ "watchedAttributes": bad }))).is_err(),
    1619              :                 "watchedAttributes {bad} must be rejected"
    1620              :             );
    1621              :         }
    1622            4 :     }
    1623              : 
    1624              :     /// Table 5.2.12-1: q is "A valid query string as per clause 4.9". The
    1625              :     /// matcher percent-decodes before parsing (4.9), so the DECODED form is
    1626              :     /// what must be validated here — otherwise an encoded paren bomb passes
    1627              :     /// creation and only unfolds inside the notification task.
    1628              :     #[test]
    1629            4 :     fn clause_5_2_12_q_is_validated_in_its_decoded_form() {
    1630            4 :         assert!(norm(&sub(json!({"q": "temperature>20"}))).is_ok());
    1631            4 :         assert!(norm(&sub(json!({"q": "(("}))).is_err());
    1632            4 :         assert!(
    1633            4 :             norm(&sub(json!({"q": "%28%28%28"}))).is_err(),
    1634              :             "a percent-encoded unbalanced expression must be rejected at creation"
    1635              :         );
    1636            4 :         assert!(norm(&sub(json!({"q": 5}))).is_err());
    1637            4 :     }
    1638              : 
    1639              :     /// Table 5.2.12-1: "At least one of (a) entities or (b)
    1640              :     /// watchedAttributes shall be present, unless the member localOnly is
    1641              :     /// set to true"; timeInterval excludes watchedAttributes and throttling.
    1642              :     #[test]
    1643            4 :     fn clause_5_2_12_mutual_exclusions_and_local_only() {
    1644            4 :         let bare = json!({
    1645            4 :             "type": "Subscription",
    1646            4 :             "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
    1647              :         });
    1648            4 :         assert!(
    1649            4 :             norm(&bare).is_err(),
    1650              :             "neither entities nor watchedAttributes"
    1651              :         );
    1652            4 :         let mut local = bare.clone();
    1653            4 :         local["localOnly"] = json!(true);
    1654            4 :         assert!(
    1655            4 :             norm(&local).is_ok(),
    1656              :             "localOnly=true waives the rule (5.5.13)"
    1657              :         );
    1658            4 :         assert!(norm(&sub(json!({
    1659            4 :             "timeInterval": 5,
    1660            4 :             "watchedAttributes": ["temperature"]
    1661            4 :         })))
    1662            4 :         .is_err());
    1663            4 :         assert!(norm(&sub(json!({"timeInterval": 5, "throttling": 5}))).is_err());
    1664              :         // a fragment carries no mandatory members of its own (5.8.2.4)
    1665            4 :         assert!(frag(&json!({"isActive": false})).is_ok());
    1666            4 :     }
    1667              : 
    1668              :     /// Table 5.2.33-1 EntitySelector: type is required, id is "String or
    1669              :     /// String[]" of valid URIs, idPattern is a regular expression; 4.17
    1670              :     /// type-selection expressions stay unexpanded.
    1671              :     #[test]
    1672            4 :     fn clause_5_2_33_entity_selector_contract() {
    1673            4 :         let n = norm(&sub(json!({"entities": [{"type": "Building|Room"}]}))).expect("valid");
    1674            4 :         assert_eq!(
    1675            4 :             n["entities"][0]["type"], "Building|Room",
    1676              :             "a 4.17 type-selection expression is evaluated at match time"
    1677              :         );
    1678            4 :         let n = norm(&sub(json!({
    1679            4 :             "entities": [{"type": "Building", "id": ["urn:ngsi-ld:B:1", "urn:ngsi-ld:B:2"]}]
    1680            4 :         })))
    1681            4 :         .expect("valid");
    1682            4 :         assert_eq!(n["entities"][0]["id"][1], "urn:ngsi-ld:B:2");
    1683           40 :         for bad in [
    1684            4 :             json!([]),
    1685            4 :             json!("Building"),
    1686            4 :             json!([{"id": "urn:ngsi-ld:B:1"}]),
    1687            4 :             json!([{"type": ""}]),
    1688            4 :             json!([{"type": "Building", "id": "not a uri"}]),
    1689            4 :             json!([{"type": "Building", "id": ["urn:ngsi-ld:B:1", 7]}]),
    1690            4 :             json!([{"type": "Building", "idPattern": "["}]),
    1691            4 :             // 21 bytes, a 16 MiB automaton: above the compile ceiling the
    1692            4 :             // pattern is refused here rather than compiled per event
    1693            4 :             json!([{"type": "Building", "idPattern": r"(?:\p{Any}{100}){100}"}]),
    1694            4 :             json!([{"type": "Building", "idPattern": 7}]),
    1695            4 :             json!(["Building"]),
    1696            4 :         ] {
    1697           40 :             assert!(
    1698           40 :                 norm(&sub(json!({ "entities": bad }))).is_err(),
    1699              :                 "entities {bad} must be rejected"
    1700              :             );
    1701              :         }
    1702            4 :     }
    1703              : 
    1704              :     // ---------- 5.2.14 / 5.2.15 notification parameters ----------
    1705              : 
    1706              :     /// Table 5.2.15-1 Endpoint: uri is mandatory and a valid URI; cooldown
    1707              :     /// and timeout are Numbers "Greater than 0"; accept is one of the three
    1708              :     /// media types. Which schemes are deliverable is the sink registry's
    1709              :     /// question, answered by `check_endpoint` where the state is in hand.
    1710              :     #[test]
    1711            4 :     fn clause_5_2_15_endpoint_contract() {
    1712          132 :         let mk = |ep: Value| sub(json!({"notification": {"endpoint": ep}}));
    1713            4 :         assert!(
    1714            4 :             norm(&sub(json!({"notification": {}}))).is_err(),
    1715              :             "endpoint required"
    1716              :         );
    1717            4 :         assert!(norm(&mk(json!({}))).is_err(), "endpoint.uri required");
    1718            4 :         assert!(norm(&mk(json!({"uri": "no-scheme"}))).is_err());
    1719            4 :         assert!(norm(&mk(json!({"uri": "http://a/x\r\nX: y"}))).is_err());
    1720            8 :         for key in ["cooldown", "timeout"] {
    1721           24 :             for bad in [json!(0), json!(-1), json!("5")] {
    1722           24 :                 assert!(
    1723           24 :                     norm(&mk(json!({"uri": "http://a/x", key: bad}))).is_err(),
    1724              :                     "endpoint.{key} {bad} must be rejected"
    1725              :                 );
    1726              :             }
    1727            8 :             assert!(norm(&mk(json!({"uri": "http://a/x", key: 1.5}))).is_ok());
    1728              :         }
    1729            8 :         for key in ["receiverInfo", "notifierInfo"] {
    1730           24 :             for bad in [
    1731            8 :                 json!({}),
    1732            8 :                 json!([{"key": "k"}]),
    1733            8 :                 json!([{"key": 1, "value": "v"}]),
    1734            8 :             ] {
    1735           24 :                 assert!(
    1736           24 :                     norm(&mk(json!({"uri": "http://a/x", key: bad}))).is_err(),
    1737              :                     "endpoint.{key} {bad} must be rejected"
    1738              :                 );
    1739              :             }
    1740            8 :             assert!(norm(&mk(
    1741            8 :                 json!({"uri": "http://a/x", key: [{"key": "k", "value": "v"}]})
    1742            8 :             ))
    1743            8 :             .is_ok());
    1744              :         }
    1745              :         // 6.3.8/6.3.9: each receiverInfo pair becomes one custom HTTP header,
    1746              :         // and "'Key' and 'value' members shall adhere to IETF RFC 7230 ...
    1747              :         // definitions concerning HTTP headers" — so a pair that cannot be a
    1748              :         // header is input the operation cannot meet, not a delivery that fails
    1749              :         // later.
    1750           32 :         for bad in [
    1751            4 :             json!([{"key": "", "value": "v"}]),
    1752            4 :             json!([{"key": "Bad Key", "value": "v"}]),
    1753            4 :             json!([{"key": "X:Y", "value": "v"}]),
    1754            4 :             json!([{"key": "X\r\nInjected", "value": "v"}]),
    1755            4 :             json!([{"key": "X", "value": "a\r\nInjected: 1"}]),
    1756            4 :             json!([{"key": "X", "value": "tab\u{7f}del"}]),
    1757            4 :             json!([{"key": "X", "value": " leading"}]),
    1758            4 :             json!([{"key": "X", "value": "trailing "}]),
    1759            4 :         ] {
    1760           32 :             assert!(
    1761           32 :                 norm(&mk(json!({"uri": "http://a/x", "receiverInfo": bad}))).is_err(),
    1762              :                 "receiverInfo {bad} must be rejected"
    1763              :             );
    1764              :         }
    1765           16 :         for ok in [
    1766            4 :             json!([{"key": "Authorization", "value": "Bearer t"}]),
    1767            4 :             json!([{"key": "X-Custom_1!", "value": ""}]),
    1768            4 :             json!([{"key": "Prefer", "value": "body=json"}]),
    1769            4 :             json!([{"key": "X", "value": "a\tb"}]),
    1770            4 :         ] {
    1771           16 :             assert!(
    1772           16 :                 norm(&mk(json!({"uri": "http://a/x", "receiverInfo": ok}))).is_ok(),
    1773              :                 "receiverInfo {ok} must be accepted"
    1774              :             );
    1775              :         }
    1776            4 :         assert!(norm(&mk(json!({"uri": "http://a/x", "accept": "text/html"}))).is_err());
    1777            4 :         assert!(norm(&mk(
    1778            4 :             json!({"uri": "http://a/x", "accept": "application/geo+json"})
    1779            4 :         ))
    1780            4 :         .is_ok());
    1781            4 :     }
    1782              : 
    1783              :     /// Table 5.2.14.1-1 NotificationParams: format value space, join /
    1784              :     /// joinLevel, boolean sysAttrs/showChanges, and the pick/omit/attributes
    1785              :     /// exclusivity — "A synonym for pick, except that id, type, scope are
    1786              :     /// not allowed."
    1787              :     #[test]
    1788            4 :     fn clause_5_2_14_notification_params_contract() {
    1789          128 :         let mk = |extra: Value| {
    1790          128 :             let mut n = json!({"endpoint": {"uri": "http://localhost:1111/notify"}});
    1791          128 :             let o = n.as_object_mut().expect("object");
    1792          144 :             for (k, v) in extra.as_object().expect("object") {
    1793          144 :                 o.insert(k.clone(), v.clone());
    1794          144 :             }
    1795          128 :             sub(json!({ "notification": n }))
    1796          128 :         };
    1797            4 :         assert!(
    1798            4 :             norm(&sub(json!({"notification": []}))).is_err(),
    1799              :             "not an object"
    1800              :         );
    1801            4 :         assert!(norm(&mk(json!({"format": "verbose"}))).is_err());
    1802           16 :         for f in ["normalized", "keyValues", "simplified", "concise"] {
    1803           16 :             assert!(norm(&mk(json!({ "format": f }))).is_ok(), "{f}");
    1804              :         }
    1805           12 :         for bad in [json!("nested"), json!(1), json!("")] {
    1806           12 :             assert!(norm(&mk(json!({ "join": bad }))).is_err(), "join {bad}");
    1807              :         }
    1808           12 :         for good in ["flat", "inline", "@none"] {
    1809           12 :             assert!(norm(&mk(json!({ "join": good }))).is_ok(), "{good}");
    1810              :         }
    1811              :         // Table 5.2.14.1-1 restricts joinLevel to a positive integer, and the
    1812              :         // depth it names is the same Linked Entity traversal the query
    1813              :         // parameter drives — so the ceiling this deployment publishes as
    1814              :         // maxJoinLevel bounds it on both surfaces, not on the query alone.
    1815            4 :         let cap = crate::bounds::MAX_JOIN_LEVEL;
    1816           24 :         for bad in [
    1817            4 :             json!(0),
    1818            4 :             json!(-1),
    1819            4 :             json!("2"),
    1820            4 :             json!(1.5),
    1821            4 :             json!(cap + 1),
    1822            4 :             json!(u64::MAX),
    1823            4 :         ] {
    1824           24 :             assert!(
    1825           24 :                 norm(&mk(json!({ "joinLevel": bad }))).is_err(),
    1826              :                 "joinLevel {bad}"
    1827              :             );
    1828              :         }
    1829            4 :         assert!(norm(&mk(json!({"joinLevel": 1}))).is_ok());
    1830            4 :         assert!(norm(&mk(json!({ "joinLevel": cap }))).is_ok(), "at the cap");
    1831            8 :         for key in ["sysAttrs", "showChanges"] {
    1832            8 :             assert!(
    1833            8 :                 norm(&mk(json!({ key: "true" }))).is_err(),
    1834              :                 "{key} must be a boolean"
    1835              :             );
    1836            8 :             assert!(norm(&mk(json!({ key: true }))).is_ok());
    1837              :         }
    1838           12 :         for name in ["id", "type", "scope"] {
    1839           12 :             assert!(
    1840           12 :                 norm(&mk(json!({"attributes": [name]}))).is_err(),
    1841              :                 "notification.attributes may not name {name}"
    1842              :             );
    1843              :         }
    1844            4 :         assert!(norm(&mk(json!({"attributes": [1]}))).is_err());
    1845            4 :         assert!(norm(&mk(json!({"attributes": ["a"], "pick": ["b"]}))).is_err());
    1846            4 :         assert!(norm(&mk(json!({"attributes": ["a"], "omit": ["b"]}))).is_err());
    1847            4 :         assert!(norm(&mk(json!({"pick": ["a"], "omit": ["a"]}))).is_err());
    1848            4 :         assert!(norm(&mk(json!({"pick": ["a"], "omit": ["b"]}))).is_ok());
    1849              :         // attribute names expand per 5.5.7
    1850            4 :         let n = norm(&mk(json!({"attributes": ["temperature"]}))).expect("valid");
    1851            4 :         assert_eq!(
    1852            4 :             n["notification"]["attributes"][0],
    1853              :             "https://uri.etsi.org/ngsi-ld/default-context/temperature"
    1854              :         );
    1855            4 :     }
    1856              : 
    1857              :     /// Table 5.2.14.1-1: a `pick` or `omit` member is "a valid attribute
    1858              :     /// projection language string as per clause 4.21". The notification path
    1859              :     /// parses the member again at delivery time and drops one it cannot
    1860              :     /// parse, so a Subscription accepted with an unparseable `omit` delivers
    1861              :     /// the Attribute the subscriber asked to have removed — the failure is
    1862              :     /// silent and it is in the leaking direction. The value space belongs at
    1863              :     /// the door, where 5.8.1.4 raises BadRequestData for a Subscription that
    1864              :     /// does not validate.
    1865              :     #[test]
    1866            4 :     fn clause_5_2_14_pick_and_omit_members_are_projection_language() {
    1867           56 :         let mk = |extra: Value| {
    1868           56 :             let mut n = json!({"endpoint": {"uri": "http://localhost:1111/notify"}});
    1869           56 :             let o = n.as_object_mut().expect("object");
    1870           56 :             for (k, v) in extra.as_object().expect("object") {
    1871           56 :                 o.insert(k.clone(), v.clone());
    1872           56 :             }
    1873           56 :             sub(json!({ "notification": n }))
    1874           56 :         };
    1875            8 :         for key in ["pick", "omit"] {
    1876           48 :             for term in [
    1877            8 :                 json!("street address"), // a space is outside the 4.21 character set
    1878            8 :                 json!("{model"),         // unbalanced brace
    1879            8 :                 json!("a,,b"),           // empty projection member
    1880            8 :                 json!(""),               // empty term
    1881            8 :                 json!("{model}"),        // a LinkedEntityTerm with no AttrName
    1882            8 :                 json!(42),               // not a String
    1883            8 :             ] {
    1884           48 :                 assert!(
    1885           48 :                     norm(&mk(json!({ key: [term] }))).is_err(),
    1886              :                     "notification.{key} member {term} is not 4.21 projection language"
    1887              :                 );
    1888              :             }
    1889            8 :             assert!(
    1890            8 :                 norm(&mk(json!({ key: ["refDevice{model}", "temperature"] }))).is_ok(),
    1891              :                 "a nested term and a bare term are both valid 4.21 (notification.{key})"
    1892              :             );
    1893              :             // 5.8.2.4 replaces the member from a fragment, so the same value
    1894              :             // space has to hold on that way in: the delivery path cannot tell
    1895              :             // which operation wrote the Subscription it reads. The fragment
    1896              :             // carries a complete endpoint deliberately — `notification`
    1897              :             // without one is refused for the missing endpoint (5.2.14,
    1898              :             // cardinality 1), which would make this assertion pass while
    1899              :             // proving nothing about the projection member.
    1900            8 :             let ctx = antares_jsonld::Loader::new().core();
    1901            8 :             let fragment = json!({"notification": {
    1902            8 :                 "endpoint": {"uri": "http://localhost:1111/notify"}, key: ["a b"]}});
    1903            8 :             assert!(
    1904            8 :                 normalize_subscription(fragment.as_object().expect("object"), &ctx, true).is_err(),
    1905              :                 "an update fragment wrote a notification.{key} the 4.21 grammar refuses"
    1906              :             );
    1907              :         }
    1908            4 :     }
    1909              : 
    1910              :     /// Table 5.2.13-1 GeoQuery: georel is mandatory and the geoproperty
    1911              :     /// name expands per 5.5.7.
    1912              :     #[test]
    1913            4 :     fn clause_5_2_13_geo_q_contract() {
    1914            4 :         let ok = json!({
    1915            4 :             "georel": "near;maxDistance==2000",
    1916            4 :             "geometry": "Point",
    1917            4 :             "coordinates": [-8.5, 41.2],
    1918            4 :             "geoproperty": "location"
    1919              :         });
    1920            4 :         let n = norm(&sub(json!({ "geoQ": ok }))).expect("valid");
    1921            4 :         assert_eq!(
    1922            4 :             n["geoQ"]["geoproperty"],
    1923              :             "https://uri.etsi.org/ngsi-ld/location"
    1924              :         );
    1925           12 :         for bad in [
    1926            4 :             json!("near"),
    1927            4 :             json!({"geometry": "Point", "coordinates": [1, 2]}),
    1928            4 :             json!({"georel": "sideways", "geometry": "Point", "coordinates": [1, 2]}),
    1929            4 :         ] {
    1930           12 :             assert!(
    1931           12 :                 norm(&sub(json!({ "geoQ": bad }))).is_err(),
    1932              :                 "geoQ {bad} must be rejected"
    1933              :             );
    1934              :         }
    1935            4 :     }
    1936              : 
    1937              :     /// 5.2.21 TemporalQuery, used by Context Source Registration
    1938              :     /// Subscriptions (5.11): timerel and timeAt are cardinality 1.
    1939              :     #[test]
    1940            4 :     fn clause_5_2_21_temporal_q_contract() {
    1941            4 :         assert!(norm(&sub(json!({
    1942            4 :             "temporalQ": {"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"}
    1943            4 :         })))
    1944            4 :         .is_ok());
    1945           16 :         for bad in [
    1946            4 :             json!("after"),
    1947            4 :             json!({"timerel": "after"}),
    1948            4 :             json!({"timerel": "sideways", "timeAt": "2020-01-01T00:00:00Z"}),
    1949            4 :             json!({"timerel": "after", "timeAt": "yesterday"}),
    1950            4 :         ] {
    1951           16 :             assert!(
    1952           16 :                 norm(&sub(json!({ "temporalQ": bad }))).is_err(),
    1953              :                 "temporalQ {bad} must be rejected"
    1954              :             );
    1955              :         }
    1956            4 :     }
    1957              : 
    1958              :     // ---------- 5.8.3 / 5.8.4 presentation ----------
    1959              : 
    1960              :     /// 5.2.12 Table 5.2.12-2: status is "Provided by the system"; 5.8.2.4
    1961              :     /// fixes its value space to active | paused | expired. The default
    1962              :     /// notificationTrigger is surfaced for entity Subscriptions only, and
    1963              :     /// createdAt/modifiedAt stay behind the sysAttrs gate.
    1964              :     #[test]
    1965            4 :     fn clause_5_8_3_presented_subscription_shape() {
    1966            4 :         let ctx = Loader::new().core();
    1967            4 :         let base = json!({
    1968            4 :             "id": "urn:ngsi-ld:Subscription:p1",
    1969            4 :             "type": "Subscription",
    1970            4 :             "entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Building"}],
    1971            4 :             "watchedAttributes": ["https://uri.etsi.org/ngsi-ld/default-context/temperature"],
    1972            4 :             "notification": {
    1973            4 :                 "endpoint": {"uri": "http://localhost:1111/notify"},
    1974            4 :                 "attributes": ["https://uri.etsi.org/ngsi-ld/default-context/temperature"]
    1975              :             },
    1976            4 :             "geoQ": {"georel": "near;maxDistance==1", "geoproperty": "https://uri.etsi.org/ngsi-ld/location"},
    1977            4 :             "createdAt": "2020-01-01T00:00:00Z",
    1978            4 :             "modifiedAt": "2020-01-01T00:00:00Z",
    1979              :         });
    1980            4 :         let out = present_subscription(&base, &ctx, false, false);
    1981            4 :         assert_eq!(out["status"], "active");
    1982            4 :         assert_eq!(out["entities"][0]["type"], "Building");
    1983            4 :         assert_eq!(out["watchedAttributes"][0], "temperature");
    1984            4 :         assert_eq!(out["notification"]["attributes"][0], "temperature");
    1985            4 :         assert_eq!(out["geoQ"]["geoproperty"], "location");
    1986            4 :         assert!(
    1987            4 :             out.get("createdAt").is_none() && out.get("modifiedAt").is_none(),
    1988              :             "sysAttrs are gated (6.3.11): {out}"
    1989              :         );
    1990            4 :         assert_eq!(
    1991            4 :             out["notificationTrigger"],
    1992            4 :             json!(["attributeCreated", "attributeUpdated"])
    1993              :         );
    1994            4 :         let sys = present_subscription(&base, &ctx, true, false);
    1995            4 :         assert_eq!(sys["createdAt"], "2020-01-01T00:00:00Z");
    1996              :         // a Context Source Registration Subscription has no such default
    1997            4 :         let cs = present_subscription(&base, &ctx, false, true);
    1998            4 :         assert!(cs.get("notificationTrigger").is_none(), "{cs}");
    1999              : 
    2000            4 :         let mut paused = base.clone();
    2001            4 :         paused["isActive"] = json!(false);
    2002            4 :         assert_eq!(
    2003            4 :             present_subscription(&paused, &ctx, false, false)["status"],
    2004              :             "paused"
    2005              :         );
    2006            4 :         let mut expired = base.clone();
    2007            4 :         expired["expiresAt"] = json!("2020-01-01T00:00:00Z");
    2008            4 :         expired["isActive"] = json!(false);
    2009            4 :         assert_eq!(
    2010            4 :             present_subscription(&expired, &ctx, false, false)["status"],
    2011              :             "expired",
    2012              :             "expiry wins over paused (5.8.2.4)"
    2013              :         );
    2014            4 :         let mut periodic = base.clone();
    2015            4 :         periodic["timeInterval"] = json!(30);
    2016            4 :         assert!(
    2017            4 :             present_subscription(&periodic, &ctx, false, false)
    2018            4 :                 .get("notificationTrigger")
    2019            4 :                 .is_none(),
    2020              :             "a periodic subscription has no attribute triggers"
    2021              :         );
    2022            4 :     }
    2023              : }
        

Generated by: LCOV version 2.0-1