LCOV - code coverage report
Current view: top level - antares-api/src - registry.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 95.8 % 383 367
Test Date: 2026-11-02 13:21:27 Functions: 65.7 % 134 88

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Registration matching over a registration document: the `CsrSpec`
       3              : //! of a request or subscription, the 5.9 information/registration
       4              : //! match (4.3.6.1), the csf and scope filters, the temporal interval
       5              : //! and expiry of a registration, and the 5.11.2 subscription match.
       6              : //! Pure functions over documents; no route and no store.
       7              : 
       8              : use antares_jsonld::Context;
       9              : use antares_store::Kind;
      10              : use serde_json::{Map, Value};
      11              : use std::collections::HashMap;
      12              : 
      13              : /// 5.9.2.4: a registration whose expiresAt has been reached counts as
      14              : /// deleted — lazily filtered on every read/match path (dt_key so fraction
      15              : /// spellings cannot misorder, 4.11).
      16     10432124 : pub fn reg_expired(doc: &Value) -> bool {
      17     10432124 :     doc.get("expiresAt")
      18     10432124 :         .and_then(Value::as_str)
      19     10432124 :         .is_some_and(|e| antares_model::dt_key(e) < antares_model::dt_key(&crate::state::now_iso()))
      20     10432124 : }
      21              : 
      22              : /// Output shaping: compact IRIs.
      23          651 : pub fn present_registration(doc: &Value, ctx: &Context, sys_attrs: bool) -> Value {
      24          651 :     let Some(obj) = doc.as_object() else {
      25            0 :         return doc.clone();
      26              :     };
      27          651 :     let mut out = Map::new();
      28         4228 :     for (k, v) in obj {
      29         4228 :         match k.as_str() {
      30              :             // 5.2.9 has no member under this prefix: the creating subject
      31              :             // (policy::SUBJECT_MEMBER) stays in the broker, in a response
      32              :             // and in a 5.11 notification alike
      33         4228 :             k if k.starts_with("__") => continue,
      34         4198 :             "createdAt" | "modifiedAt" if !sys_attrs => continue,
      35         3076 :             "information" => {
      36          651 :                 let infos: Vec<Value> = v
      37          651 :                     .as_array()
      38          651 :                     .cloned()
      39          651 :                     .unwrap_or_default()
      40          651 :                     .iter()
      41          711 :                     .map(|info| {
      42          711 :                         let mut ni = info.as_object().cloned().unwrap_or_default();
      43          711 :                         if let Some(es) = ni.get("entities").and_then(Value::as_array) {
      44          707 :                             let nes: Vec<Value> = es
      45          707 :                                 .iter()
      46          767 :                                 .map(|e| {
      47          767 :                                     let mut ne = e.as_object().cloned().unwrap_or_default();
      48          767 :                                     match ne.get("type") {
      49          759 :                                         Some(Value::String(t)) => {
      50          759 :                                             let c = ctx.compact_iri(t);
      51          759 :                                             ne.insert("type".into(), Value::String(c));
      52          759 :                                         }
      53            8 :                                         Some(Value::Array(ts)) => {
      54            8 :                                             let cs: Vec<Value> = ts
      55            8 :                                                 .iter()
      56            8 :                                                 .filter_map(Value::as_str)
      57           16 :                                                 .map(|t| Value::String(ctx.compact_iri(t)))
      58            8 :                                                 .collect();
      59            8 :                                             ne.insert("type".into(), Value::Array(cs));
      60              :                                         }
      61            0 :                                         _ => {}
      62              :                                     }
      63          767 :                                     Value::Object(ne)
      64          767 :                                 })
      65          707 :                                 .collect();
      66          707 :                             ni.insert("entities".into(), Value::Array(nes));
      67            4 :                         }
      68         2133 :                         for names_key in antares_model::REGISTRATION_ATTRIBUTE_MEMBERS {
      69         2133 :                             if let Some(names) = ni.get(names_key).and_then(Value::as_array) {
      70          480 :                                 let nn: Vec<Value> = names
      71          480 :                                     .iter()
      72          480 :                                     .filter_map(Value::as_str)
      73          716 :                                     .map(|n| Value::String(ctx.compact_iri(n)))
      74          480 :                                     .collect();
      75          480 :                                 ni.insert(names_key.into(), Value::Array(nn));
      76         1653 :                             }
      77              :                         }
      78          711 :                         Value::Object(ni)
      79          711 :                     })
      80          651 :                     .collect();
      81          651 :                 out.insert("information".into(), Value::Array(infos));
      82              :             }
      83         2425 :             _ => {
      84         2425 :                 out.insert(k.clone(), v.clone());
      85         2425 :             }
      86              :         }
      87              :     }
      88          651 :     Value::Object(out)
      89          651 : }
      90              : 
      91              : /// 5.10.2.4 temporal matching against observationInterval/managementInterval.
      92           32 : pub(crate) fn temporal_interval_matches(doc: &Value, tq: &crate::temporalq::TemporalQ) -> bool {
      93           32 :     let key = if tq.timeproperty == "observedAt" {
      94           16 :         "observationInterval"
      95              :     } else {
      96           16 :         "managementInterval"
      97              :     };
      98           32 :     let Some(iv) = doc.get(key).and_then(Value::as_object) else {
      99            0 :         return false; // relevant interval not present ⇒ no match
     100              :     };
     101              :     // 4.11 comparison on the canonical key — equal instants in different
     102              :     // 4.6.3 fraction spellings must hit the bounds exactly.
     103           32 :     let dt = antares_model::dt_key;
     104           32 :     let start = dt(iv.get("startAt").and_then(Value::as_str).unwrap_or(""));
     105           32 :     let end = iv.get("endAt").and_then(Value::as_str).map(dt); // open-ended when absent
     106           32 :     match tq.timerel.as_str() {
     107              :         // interval contains times before/after timeAt (037_09, 047_10/11)
     108           32 :         "before" => start < dt(&tq.time_at),
     109           16 :         "after" => end.is_none_or(|e| e > dt(&tq.time_at)),
     110            0 :         "between" => {
     111              :             // overlap between [timeAt, endTimeAt] and the interval
     112            0 :             let qe = dt(tq.end_time_at.as_deref().unwrap_or(&tq.time_at));
     113            0 :             dt(&tq.time_at) <= end.unwrap_or_else(|| "9999".into()) && qe >= start
     114              :         }
     115            0 :         _ => true,
     116              :     }
     117           32 : }
     118              : 
     119              : /// The entity/attribute specification matched against registrations (5.12).
     120              : #[derive(Default)]
     121              : pub struct CsrSpec {
     122              :     /// Expanded type IRIs (or raw 4.17 selector expressions).
     123              :     pub types: Option<Vec<String>>,
     124              :     pub ids: Option<Vec<String>>,
     125              :     pub id_pattern: Option<String>,
     126              :     /// Expanded attribute IRIs.
     127              :     pub attrs: Option<Vec<String>>,
     128              :     /// 5.12 datasetId condition (should-level): with both the request and
     129              :     /// the CSourceRegistration specifying datasetId, they match only with
     130              :     /// "at least one value in common"; one side alone always matches.
     131              :     pub dataset_ids: Option<Vec<String>>,
     132              :     /// 4.9 Context Source Filter: with a csf present, only registrations
     133              :     /// whose Context Source Properties match it are considered (query,
     134              :     /// temporal query, purge, entityMaps — 5.7.2.4/5.7.4.4/5.6.21.4).
     135              :     pub csf: Option<antares_ql::QNode>,
     136              :     /// 5.2.9 location ("Location for which the Context Source may be able
     137              :     /// to provide information") + 4.3.6.1: a geo query is only distributed
     138              :     /// to registrations whose location geometry matches it.
     139              :     pub geo: Option<antares_ql::geo::GeoQuery>,
     140              :     /// 5.2.9 observationInterval/managementInterval: "matched against the
     141              :     /// observationInterval for overlap" — a temporal read is only
     142              :     /// distributed to registrations whose declared interval overlaps the
     143              :     /// temporal query; a registration declaring NO interval stays
     144              :     /// unconstrained (both members are optional).
     145              :     pub temporal: Option<crate::temporalq::TemporalQ>,
     146              : }
     147              : 
     148              : /// 5.12: does an EntityInfo element match the entity specification?
     149        34974 : fn entity_info_matches(spec: &CsrSpec, ei: &Value, ctx: &Context) -> bool {
     150        34974 :     if let Some(types) = &spec.types {
     151        33942 :         let its = ei_types(ei);
     152              :         // EntityInfo without a type restricts only by id/idPattern. Each spec
     153              :         // entry is a 4.17 Entity Type Selection over the WHOLE declared type
     154              :         // list (a conjunction needs every named type present); a plain
     155              :         // expanded IRI is the one-term case of the same evaluation.
     156        33942 :         if !its.is_empty()
     157        33958 :             && !types.iter().any(|t| {
     158        33958 :                 its.contains(&t.as_str()) || antares_ql::type_selection_matches(t, &its, ctx)
     159        33958 :             })
     160              :         {
     161          304 :             return false;
     162        33638 :         }
     163         1032 :     }
     164        34670 :     let ei_id = ei.get("id").and_then(Value::as_str);
     165        34670 :     let ei_pat = ei.get("idPattern").and_then(Value::as_str);
     166        34670 :     if ei_id.is_none() && ei_pat.is_none() {
     167         1750 :         return true;
     168        32920 :     }
     169        32920 :     if let Some(ids) = &spec.ids {
     170        32838 :         if let Some(rid) = ei_id {
     171         1804 :             if ids.iter().any(|i| i == rid) {
     172         1782 :                 return true;
     173           18 :             }
     174        31038 :         }
     175        31056 :         if let Some(p) = ei_pat {
     176        31038 :             if let Ok(re) = antares_ql::regex::compile(p) {
     177        31048 :                 if ids.iter().any(|i| re.find(i).is_some()) {
     178          298 :                     return true;
     179        30740 :                 }
     180            0 :             }
     181           18 :         }
     182           82 :     }
     183        30840 :     if let Some(qp) = &spec.id_pattern {
     184           24 :         if let Some(rid) = ei_id {
     185           12 :             if antares_ql::regex::compile(qp).is_ok_and(|re| re.find(rid).is_some()) {
     186            6 :                 return true;
     187            6 :             }
     188           12 :         }
     189           18 :         if ei_pat.is_some() {
     190           12 :             return true; // both patterns present ⇒ assumed compatible (5.12)
     191            6 :         }
     192        30816 :     }
     193              :     // no id restriction given by the query side ⇒ EntityInfo id restrictions
     194              :     // don't exclude it when the type matched
     195        30822 :     spec.ids.is_none() && spec.id_pattern.is_none()
     196        34974 : }
     197              : 
     198         3936 : fn attrs_match_info(attrs: &Option<Vec<String>>, info: &Value) -> bool {
     199         3936 :     let Some(attrs) = attrs else { return true };
     200         1658 :     if attrs.is_empty() {
     201            0 :         return true;
     202         1658 :     }
     203              :     // TS 104 175 9.4.2: attributeNames, else the deprecated pair
     204         1658 :     match antares_model::registration_attribute_names(info) {
     205          478 :         None => true,
     206         1254 :         Some(names) => names.iter().any(|n| attrs.iter().any(|w| w == n)),
     207              :     }
     208         3936 : }
     209              : 
     210              : /// 5.12: the RegistrationInfo elements of `doc.information` that match `spec`.
     211        34902 : pub fn matching_infos<'a>(spec: &CsrSpec, doc: &'a Value, ctx: &Context) -> Vec<&'a Value> {
     212        34902 :     let Some(infos) = doc.get("information").and_then(Value::as_array) else {
     213            0 :         return Vec::new();
     214              :     };
     215        34902 :     infos
     216        34902 :         .iter()
     217        34956 :         .filter(|info| {
     218        34956 :             let entity_ok = match info.get("entities").and_then(Value::as_array) {
     219           22 :                 None => true,
     220        34974 :                 Some(es) => es.iter().any(|ei| entity_info_matches(spec, ei, ctx)),
     221              :             };
     222        34956 :             entity_ok && attrs_match_info(&spec.attrs, info)
     223        34956 :         })
     224        34902 :         .collect()
     225        34902 : }
     226              : 
     227              : /// 5.10.2.4: the context source filter (csf, 4.9) evaluates over the
     228              : /// registration document's own Context Source Properties — its members are
     229              : /// wrapped as Property instances so the shared 4.9 evaluator applies.
     230          110 : pub(crate) fn csf_matches(csf: &antares_ql::QNode, reg: &Value, ctx: &Context) -> bool {
     231          110 :     let Some(obj) = reg.as_object() else {
     232            0 :         return false;
     233              :     };
     234          110 :     let mut pseudo = Map::new();
     235          110 :     pseudo.insert("id".into(), obj.get("id").cloned().unwrap_or(Value::Null));
     236          110 :     pseudo.insert(
     237          110 :         "type".into(),
     238          110 :         serde_json::json!(["ContextSourceRegistration"]),
     239              :     );
     240          910 :     for (k, v) in obj {
     241              :         // the `__` members are the broker's (policy::SUBJECT_MEMBER), not
     242              :         // Context Source Properties a client may filter on
     243          910 :         if ["id", "type", "information", "createdAt", "modifiedAt"].contains(&k.as_str())
     244          448 :             || k.starts_with("__")
     245              :         {
     246          538 :             continue;
     247          372 :         }
     248              :         // a Context Source Property stored in attribute form (5.2.9) is an
     249              :         // instance already — only bare scalars need the Property wrap
     250          372 :         let inst = match v {
     251           56 :             Value::Array(_) => v.clone(),
     252           50 :             Value::Object(o) if o.contains_key("value") || o.contains_key("object") => {
     253           42 :                 serde_json::json!([v])
     254              :             }
     255          274 :             _ => serde_json::json!([{"type": "Property", "value": v}]),
     256              :         };
     257          372 :         pseudo.insert(ctx.expand_key(k), inst);
     258              :     }
     259          110 :     antares_ql::eval::eval_q(csf, &Value::Object(pseudo), ctx, &|_| None)
     260          110 : }
     261              : 
     262         2017 : pub fn csr_matches(spec: &CsrSpec, doc: &Value, ctx: &Context) -> bool {
     263         2017 :     !matching_infos(spec, doc, ctx).is_empty()
     264         2017 : }
     265              : 
     266              : /// Full 5.11.2.4 match of a registration against a csource subscription:
     267              : /// 5.12 entity/attr matching + temporal interval rules + geoQ vs the
     268              : /// registration's own `location`.
     269              : ///
     270              : /// Not the same rule as `antares_matcher::selector_match`, which it shares a
     271              : /// signature with: that one asks whether an ENTITY satisfies a
     272              : /// subscription's `entities` selector, this one asks whether a REGISTRATION
     273              : /// does — and a registration carries its selector inside `information`, has
     274              : /// an observation/management interval a latest-information subscription must
     275              : /// not match, and answers `geoQ` from its own `location` rather than from a
     276              : /// GeoProperty of the data. The part that IS the same rule is the selector
     277              : /// walk, and it is not written twice: `spec_for_subscription` turns the
     278              : /// subscription into a `CsrSpec` and `csr_matches` walks it.
     279          478 : pub fn csr_matches_subscription(sub: &Value, reg: &Value, ctx: &Context) -> bool {
     280              :     // An expired registration is no longer a Context Source: it must not be
     281              :     // reported as newlyMatching, nor receive a forwarded subscription copy.
     282          478 :     if reg_expired(reg) {
     283            1 :         return false;
     284          477 :     }
     285          477 :     let spec = spec_for_subscription(sub);
     286          477 :     if !csr_matches(&spec, reg, ctx) {
     287           80 :         return false;
     288          397 :     }
     289          397 :     let has_interval =
     290          397 :         reg.get("observationInterval").is_some() || reg.get("managementInterval").is_some();
     291          397 :     match sub.get("temporalQ").and_then(Value::as_object) {
     292              :         None => {
     293          381 :             if has_interval {
     294            0 :                 return false; // latest-information sources only (5.11.2.4)
     295          381 :             }
     296              :         }
     297           16 :         Some(tq) => {
     298           16 :             let mut params: HashMap<String, String> = HashMap::new();
     299           64 :             for k in ["timerel", "timeAt", "endTimeAt", "timeproperty"] {
     300           64 :                 if let Some(s) = tq.get(k).and_then(Value::as_str) {
     301           48 :                     params.insert(k.into(), s.into());
     302           48 :                 }
     303              :             }
     304           16 :             if let Ok(Some(t)) = crate::temporalq::TemporalQ::from_params(&params, false) {
     305           16 :                 if t.timerel != "any" && !temporal_interval_matches(reg, &t) {
     306            0 :                     return false;
     307           16 :                 }
     308            0 :             }
     309              :         }
     310              :     }
     311          397 :     if let Some(g) = sub.get("geoQ").and_then(Value::as_object) {
     312           38 :         if let Ok(Some(gq)) =
     313           38 :             antares_ql::geo::GeoQuery::from_params(&antares_matcher::geo_params(g))
     314              :         {
     315              :             // Table 5.2.6.5.2-1: matched against the registration GeoJSON
     316              :             // member `geoproperty` names (location, observationSpace,
     317              :             // operationSpace), stored by its compacted name
     318           38 :             let member = if gq.geoproperty.is_empty() {
     319           14 :                 "location".to_owned()
     320              :             } else {
     321           24 :                 ctx.compact_iri(&ctx.expand_key(&gq.geoproperty))
     322              :             };
     323           38 :             match reg.get(&member) {
     324           32 :                 Some(geom) => {
     325           32 :                     if !gq.matches_geometry(geom) {
     326            8 :                         return false;
     327           24 :                     }
     328              :                 }
     329            6 :                 None => return false,
     330              :             }
     331            0 :         }
     332          359 :     }
     333              :     // 5.11.2.4: csf vs the registration's Context Source Properties, scopeQ
     334              :     // vs its scope property
     335          383 :     if let Some(csf) = sub.get("csf").and_then(Value::as_str) {
     336           28 :         match antares_ql::parse_q(csf) {
     337           28 :             Ok(ast) if csf_matches(&ast, reg, ctx) => {}
     338           12 :             _ => return false,
     339              :         }
     340          355 :     }
     341          371 :     if let Some(sq) = sub.get("scopeQ").and_then(Value::as_str) {
     342            8 :         if !antares_ql::scope::scope_matches(sq, reg) {
     343            4 :             return false;
     344            4 :         }
     345          363 :     }
     346          367 :     true
     347          478 : }
     348              : 
     349              : /// Build the 5.12 spec for a csource subscription (5.11.2.4): entities
     350              : /// selectors + watchedAttributes ∪ notification.attributes.
     351         1813 : pub fn spec_for_subscription(sub: &Value) -> CsrSpec {
     352         1813 :     let mut spec = CsrSpec::default();
     353         1813 :     if let Some(es) = sub.get("entities").and_then(Value::as_array) {
     354         1813 :         let mut types = Vec::new();
     355         1813 :         let mut ids = Vec::new();
     356         1813 :         let mut patterns = Vec::new();
     357              :         // a selector naming no id restricts no id: then neither list does
     358         1813 :         let mut unrestricted = false;
     359         1837 :         for e in es {
     360         1837 :             if let Some(t) = e.get("type").and_then(Value::as_str) {
     361         1837 :                 types.push(t.to_owned());
     362         1837 :             }
     363         1837 :             let before = ids.len();
     364         1837 :             match e.get("id") {
     365           80 :                 Some(Value::String(i)) => ids.push(i.clone()),
     366            4 :                 Some(Value::Array(a)) => {
     367            4 :                     ids.extend(a.iter().filter_map(Value::as_str).map(str::to_owned));
     368            4 :                 }
     369         1753 :                 _ => {}
     370              :             }
     371         1837 :             let pattern = e.get("idPattern").and_then(Value::as_str);
     372         1837 :             if let Some(p) = pattern {
     373           16 :                 patterns.push(format!("(?:{p})"));
     374         1821 :             }
     375         1837 :             unrestricted |= ids.len() == before && pattern.is_none();
     376              :         }
     377         1813 :         if !types.is_empty() {
     378         1813 :             spec.types = Some(types);
     379         1813 :         }
     380              :         // every selector's ids and idPatterns, as one id list
     381              :         // and one alternation
     382         1813 :         if !unrestricted {
     383           88 :             spec.ids = Some(ids).filter(|i| !i.is_empty());
     384           88 :             spec.id_pattern = Some(patterns.join("|")).filter(|p| !p.is_empty());
     385         1725 :         }
     386            0 :     }
     387         1813 :     let mut attrs: Vec<String> = sub
     388         1813 :         .get("watchedAttributes")
     389         1813 :         .and_then(Value::as_array)
     390         1813 :         .map(|a| {
     391          152 :             a.iter()
     392          152 :                 .filter_map(Value::as_str)
     393          152 :                 .map(str::to_owned)
     394          152 :                 .collect()
     395          152 :         })
     396         1813 :         .unwrap_or_default();
     397         1813 :     if let Some(na) = sub
     398         1813 :         .get("notification")
     399         1813 :         .and_then(|n| n.get("attributes"))
     400         1813 :         .and_then(Value::as_array)
     401           48 :     {
     402           48 :         attrs.extend(na.iter().filter_map(Value::as_str).map(str::to_owned));
     403         1765 :     }
     404         1813 :     if !attrs.is_empty() {
     405          200 :         spec.attrs = Some(attrs);
     406         1613 :     }
     407         1813 :     spec
     408         1813 : }
     409              : 
     410              : /// The kind one Registration Subscription id is stored under.
     411         1613 : pub(crate) fn csr_kind(id: &str) -> Kind {
     412         1613 :     if id.starts_with(INTERNAL_CSR_PREFIX) {
     413         1044 :         Kind::DistSub
     414              :     } else {
     415          569 :         Kind::CSourceSubscription
     416              :     }
     417         1613 : }
     418              : 
     419              : /// 5.8.1.4: "The mapping of the received subscriptionId with the own
     420              : /// Subscription identifier is stored" (inbound), "a mapping of the id of
     421              : /// the Context Source Registration to the received subscriptionId is
     422              : /// stored" (remotes), and "the mapping of the id of the Subscription to the
     423              : /// … Context Source Registration Subscription shall be stored" (csr_sub).
     424              : /// All three live in the store (Kind::DistSub) so persistent modes keep the
     425              : /// consumer half across restarts: one doc per (tenant, own Subscription id)
     426              : /// = {"csr_sub": id, "remotes": {reg_id: [endpoint, remote sub id]}}, plus
     427              : /// inbound index docs under the internal "distsub-index" tenant
     428              : /// (id = remote subscriptionId, doc = {"tenant", "own"}).
     429              : /// 5.8.1.4: the Registration Subscription the distributed half owns is
     430              : /// broker plumbing, not a resource a Context Source Subscriber created —
     431              : /// 5.11.5.4 lists the subscriptions clients made through 5.11.2, and this
     432              : /// one carries the internal `urn:antares:distsub:` endpoint naming the
     433              : /// tenant and the owning Subscription. It is stored under `Kind::DistSub`,
     434              : /// so the 5.11 endpoints cannot read, patch or delete it, and its id
     435              : /// namespace is what tells the two apart on the notification path.
     436              : pub(crate) const INTERNAL_CSR_PREFIX: &str = "urn:ngsi-ld:CSourceSubscription:distsub:";
     437              : 
     438              : /// 5.2.8: EntityInfo type is a String or String[] — yield every named type.
     439        51290 : pub(crate) fn ei_types(ei: &Value) -> Vec<&str> {
     440        51290 :     match ei.get("type") {
     441        51084 :         Some(Value::String(s)) => vec![s.as_str()],
     442           20 :         Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
     443          186 :         _ => Vec::new(),
     444              :     }
     445        51290 : }
     446              : 
     447              : #[cfg(test)]
     448              : mod tests {
     449              :     use super::*;
     450              :     use antares_jsonld::Loader;
     451              :     use serde_json::json;
     452              : 
     453              :     /// a CSR subscription's id arrays and every selector's
     454              :     /// idPattern restrict which registrations it watches; a selector with
     455              :     /// neither restricts none.
     456              :     #[test]
     457            4 :     fn a_subscription_spec_reads_every_selectors_ids_and_patterns() {
     458            4 :         let spec = spec_for_subscription(&json!({"entities": [
     459            4 :             {"type": "T", "id": ["urn:a", "urn:b"]},
     460            4 :             {"type": "T", "idPattern": "^urn:c"},
     461            4 :             {"type": "T", "idPattern": "^urn:d"}
     462            4 :         ]}));
     463            4 :         assert_eq!(spec.ids, Some(vec!["urn:a".to_owned(), "urn:b".to_owned()]));
     464            4 :         assert_eq!(spec.id_pattern.as_deref(), Some("(?:^urn:c)|(?:^urn:d)"));
     465            4 :         let open = spec_for_subscription(&json!({"entities": [
     466            4 :             {"type": "T", "id": "urn:a"}, {"type": "U"}
     467            4 :         ]}));
     468            4 :         assert_eq!((open.ids, open.id_pattern), (None, None));
     469            4 :     }
     470              : 
     471              :     /// TS 104 175 Table 5.2.6.5.2-1: a CSR subscription's geoQ "is matched
     472              :     /// against the specified Context Source Registration GeoJSON attributes,
     473              :     /// e.g. location, observationSpace or operationSpace": `geoproperty`
     474              :     /// names which one, `location` when it names none (12.4.2).
     475              :     /// A csf reads the Registration's Context Source Properties, never the
     476              :     /// broker's `__` members: a client whose `@context` maps a term onto
     477              :     /// the member cannot learn the subject another Registration was created
     478              :     /// by, whatever form a broker member is stored in. The 4.9 grammar refuses `__subject` and a full IRI, so
     479              :     /// a term is the only way to name it.
     480              :     #[tokio::test]
     481            4 :     async fn csf_never_reads_the_recorded_subject() {
     482            4 :         let core = Loader::new().core();
     483            4 :         let (iri, name) = (
     484            4 :             core.expand_key("__subject"),
     485            4 :             core.expand_key("registrationName"),
     486            4 :         );
     487            4 :         let other = core.expand_key("__other");
     488            4 :         let ctx = Loader::new()
     489            4 :             .resolve(&json!({"s": iri, "o": other, "n": name}))
     490            4 :             .await
     491            4 :             .unwrap_or_else(|e| panic!("{e}"));
     492            4 :         let reg = json!({
     493            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:s",
     494            4 :             "type": "ContextSourceRegistration",
     495            4 :             "information": [{"entities": [{"type": "Car"}]}],
     496            4 :             "endpoint": "http://a.example.com",
     497            4 :             "registrationName": "r",
     498            4 :             "__subject": [["X-Subject", "alice"]],
     499              :             // any broker member, whatever its form: one in Property form
     500              :             // would match if the prefix were read
     501            4 :             "__other": {"value": "alice"}
     502              :         });
     503           20 :         for q in ["s", "s[1]==\"alice\"", "s==\"alice\"", "o", "o==\"alice\""] {
     504           20 :             let csf = antares_ql::parse_q(q).unwrap_or_else(|e| panic!("{q}: {e}"));
     505           20 :             assert!(!csf_matches(&csf, &reg, &ctx), "{q}");
     506              :         }
     507            4 :         let named = antares_ql::parse_q("n").unwrap_or_else(|e| panic!("{e}"));
     508            4 :         assert!(csf_matches(&named, &reg, &ctx), "a property still matches");
     509            4 :     }
     510              : 
     511              :     #[test]
     512            4 :     fn clause_12_4_2_geoq_geoproperty_matches_observation_space() {
     513            4 :         let ctx = Loader::new().core();
     514            8 :         let point = |x: f64| json!({"type": "Point", "coordinates": [x, 0.5]});
     515            4 :         let reg = json!({
     516            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:geo",
     517            4 :             "type": "ContextSourceRegistration",
     518            4 :             "information": [{"entities": [{"type": "Building"}]}],
     519            4 :             "endpoint": "http://a.example.com",
     520            4 :             "location": point(5.0),
     521            4 :             "observationSpace": point(0.5)
     522              :         });
     523           20 :         let sub = |geoproperty: Option<&str>| {
     524           20 :             let mut geo = json!({"georel": "within", "geometry": "Polygon",
     525           20 :                 "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]});
     526           20 :             if let Some(p) = geoproperty {
     527           16 :                 geo["geoproperty"] = json!(p);
     528           16 :             }
     529           20 :             json!({"entities": [{"type": "Building"}], "geoQ": geo})
     530           20 :         };
     531            4 :         assert!(csr_matches_subscription(
     532            4 :             &sub(Some("observationSpace")),
     533            4 :             &reg,
     534            4 :             &ctx
     535              :         ));
     536            4 :         assert!(
     537            4 :             csr_matches_subscription(
     538            4 :                 &sub(Some("https://uri.etsi.org/ngsi-ld/observationSpace")),
     539            4 :                 &reg,
     540            4 :                 &ctx
     541              :             ),
     542              :             "the expanded name names the same member"
     543              :         );
     544            4 :         assert!(
     545            4 :             !csr_matches_subscription(&sub(None), &reg, &ctx),
     546              :             "location is outside"
     547              :         );
     548            4 :         assert!(!csr_matches_subscription(
     549            4 :             &sub(Some("location")),
     550            4 :             &reg,
     551            4 :             &ctx
     552            4 :         ));
     553            4 :         assert!(
     554            4 :             !csr_matches_subscription(&sub(Some("operationSpace")), &reg, &ctx),
     555              :             "a member the registration lacks matches nothing"
     556              :         );
     557            4 :     }
     558              : 
     559              :     /// 5.11.2.4: a csource subscription's csf matches the registration's
     560              :     /// own Context Source Properties, and its scopeQ matches the
     561              :     /// registration scope.
     562              :     #[test]
     563            4 :     fn csource_subscription_csf_and_scope_matching() {
     564            4 :         let ctx = Loader::new().core();
     565            4 :         let reg_a = json!({
     566            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:sub-a",
     567            4 :             "type": "ContextSourceRegistration",
     568            4 :             "information": [{"entities": [{"type": "Building"}]}],
     569            4 :             "endpoint": "http://a.example.com",
     570            4 :             "scope": "/Madrid/Centro"
     571              :         });
     572            4 :         let reg_b = json!({
     573            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:sub-b",
     574            4 :             "type": "ContextSourceRegistration",
     575            4 :             "information": [{"entities": [{"type": "Building"}]}],
     576            4 :             "endpoint": "http://b.example.com",
     577            4 :             "scope": "/Berlin"
     578              :         });
     579            4 :         let sub_csf = json!({
     580            4 :             "entities": [{"type": "Building"}],
     581            4 :             "csf": "endpoint==\"http://a.example.com\""
     582              :         });
     583            4 :         assert!(csr_matches_subscription(&sub_csf, &reg_a, &ctx));
     584            4 :         assert!(
     585            4 :             !csr_matches_subscription(&sub_csf, &reg_b, &ctx),
     586              :             "csf must exclude the other endpoint"
     587              :         );
     588            4 :         let sub_scope = json!({
     589            4 :             "entities": [{"type": "Building"}],
     590            4 :             "scopeQ": "/Madrid/#"
     591              :         });
     592            4 :         assert!(csr_matches_subscription(&sub_scope, &reg_a, &ctx));
     593            4 :         assert!(
     594            4 :             !csr_matches_subscription(&sub_scope, &reg_b, &ctx),
     595              :             "scopeQ must exclude /Berlin"
     596              :         );
     597            4 :     }
     598              : }
        

Generated by: LCOV version 2.0-1