LCOV - code coverage report
Current view: top level - antares-api/src - entity_map.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.3 % 655 644
Test Date: 2026-11-02 13:21:27 Functions: 64.4 % 247 159

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! One EntityMap document (5.2.39) and the rules for using one: store it
       3              : //! under its tenant with a lifetime, read it back only while it is alive,
       4              : //! take the candidate ids of a page out of it, merge the registrations a
       5              : //! distributed query reached into it, and serve a retrieve through the map
       6              : //! a client presented. The /entityMaps resource itself is `entity_maps`,
       7              : //! which composes this over the queries in `entities` and `temporal`.
       8              : 
       9              : use crate::negotiate::*;
      10              : use crate::state::AppState;
      11              : use antares_model::{NgsiError, TenantId};
      12              : use antares_store::CurrentStateDriverExt;
      13              : use antares_store::Kind;
      14              : use axum::http::{HeaderMap, StatusCode};
      15              : use axum::response::Response;
      16              : use serde_json::{json, Map, Value};
      17              : use std::collections::HashMap;
      18              : 
      19              : /// Per-tenant EntityMap cap (every buffer bounded); earliest-expiring evicted.
      20              : pub(crate) const MAX_MAPS_PER_TENANT: usize = 512;
      21              : 
      22              : /// Default lifetime when the client suggests none — 5.5.14: "the caching
      23              : /// strategy and expiry time … depend on implementation specific
      24              : /// configurations".
      25              : pub(crate) const DEFAULT_LIFETIME_SECS: i64 = 3600;
      26              : 
      27              : /// Ceiling on client-suggested lifetimes — 6.4.3.2-1: "the actual expiresAt
      28              : /// time of the EntityMap shall be set by the Context Broker or Context
      29              : /// Source, possibly overriding the requested duration".
      30              : pub(crate) const MAX_LIFETIME_SECS: i64 = 86_400;
      31              : 
      32         3853 : pub(crate) fn dt(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
      33         3853 :     chrono::DateTime::parse_from_rfc3339(s)
      34         3853 :         .ok()
      35         3853 :         .map(|d| d.with_timezone(&chrono::Utc))
      36         3853 : }
      37              : 
      38              : /// Record whose map this is, so 5.5.14's "cannot be accessed" covers a map
      39              : /// built for someone else. A broker-internal member: 5.2.39 defines none, no
      40              : /// served map carries it, and a client cannot supply one because every map
      41              : /// the broker stores is one the broker built.
      42          254 : fn stamp_subject(doc: &mut Value, tenant: &TenantId, headers: &HeaderMap) {
      43           10 :     if let (Some(o), Some(subject)) = (
      44          254 :         doc.as_object_mut(),
      45          254 :         crate::policy::subject_member(&crate::policy::subject_of(tenant, headers)),
      46           10 :     ) {
      47           10 :         o.insert(crate::policy::SUBJECT_MEMBER.into(), subject);
      48          244 :     }
      49          254 : }
      50              : 
      51              : /// Fetch a live EntityMap; an expired one "cannot be accessed" (5.5.14).
      52              : /// Reading is a read: the row behind a map this refuses is freed by
      53              : /// `sweep_expired_maps` on the sweep tick, not by the request that found
      54              : /// it. Maps live in the store (Kind::EntityMap) so persistent modes survive
      55              : /// restarts.
      56          520 : pub(crate) async fn map_get(
      57          520 :     st: &AppState,
      58          520 :     tenant: &TenantId,
      59          520 :     id: &str,
      60          520 : ) -> Result<Option<Value>, NgsiError> {
      61          520 :     let Some(doc) = st.store.get(tenant, Kind::EntityMap, id).await? else {
      62           82 :         return Ok(None);
      63              :     };
      64          430 :     Ok(map_live(&doc).then_some(doc))
      65          520 : }
      66              : 
      67              : /// 5.5.14 is a positive condition: a map is usable only while a READABLE
      68              : /// expiry is still in the future. Judging "expired" instead lets a map whose
      69              : /// `expiresAt` is missing or unparseable outlive every ceiling. One
      70              : /// definition, so the sweep reaps exactly what a read refuses.
      71         1455 : fn map_live(doc: &Value) -> bool {
      72         1455 :     doc.get("expiresAt")
      73         1455 :         .and_then(Value::as_str)
      74         1455 :         .and_then(dt)
      75         1455 :         .is_some_and(|e| e > chrono::Utc::now())
      76         1455 : }
      77              : 
      78              : /// 4.22 for EntityMaps: a map a read will not serve is a map nothing can
      79              : /// reach, and this is what removes it.
      80       145070 : pub(crate) async fn sweep_expired_maps(st: &AppState, tenant: &TenantId) -> usize {
      81       145070 :     let mut dead: Vec<String> = Vec::new();
      82       145070 :     if crate::csource::walk_docs(st, tenant, Kind::EntityMap, |doc| {
      83         1025 :         if !map_live(&doc) {
      84           24 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
      85           24 :                 dead.push(id.to_owned());
      86           24 :             }
      87         1001 :         }
      88         1025 :         Ok(())
      89         1025 :     })
      90       145070 :     .await
      91       145070 :     .is_err()
      92              :     {
      93           12 :         return 0;
      94       145058 :     }
      95       145058 :     let mut n: usize = 0;
      96       145058 :     for id in dead {
      97           24 :         if st
      98           24 :             .store
      99           24 :             .delete(tenant, Kind::EntityMap, &id)
     100           24 :             .await
     101           24 :             .unwrap_or(false)
     102           24 :         {
     103           24 :             n = n.saturating_add(1);
     104           24 :         }
     105              :     }
     106       145058 :     n
     107       145070 : }
     108              : 
     109              : /// The map a consumption request named, or nothing.
     110              : ///
     111              : /// 5.5.14: "If an EntityMap has expired, or cannot be accessed, no inference
     112              : /// can be made as to which entities are held within the Context Sources and a
     113              : /// new one shall be created." A store that refuses the read is one way a map
     114              : /// cannot be accessed, so these paths recover the way they recover from an
     115              : /// expiry — with a new map — instead of failing the request.
     116              : /// A map built for a DIFFERENT subject is one this one cannot access, so the
     117              : /// clause's own recovery applies: a new map is created for this request. It
     118              : /// has to be that and not a refusal — the map id came from a header the
     119              : /// client may well be replaying honestly, and an error would tell it that
     120              : /// someone else's transaction exists (ADR-0020).
     121           76 : pub(crate) async fn map_if_accessible(
     122           76 :     st: &AppState,
     123           76 :     tenant: &TenantId,
     124           76 :     headers: &HeaderMap,
     125           76 :     id: &str,
     126           76 : ) -> Option<Value> {
     127           76 :     map_get(st, tenant, id)
     128           76 :         .await
     129           76 :         .ok()
     130           76 :         .flatten()
     131           76 :         .filter(|doc| crate::policy::belongs_to(doc, &crate::policy::subject_of(tenant, headers)))
     132           76 : }
     133              : 
     134              : /// The Entities of a map a given request may be answered from.
     135              : ///
     136              : /// 5.5.9.3: "the set of Entities considered for the result is fixed with the
     137              : /// initial query creating the Entity map." The map is the CANDIDATE set; the
     138              : /// request's own filters still apply on top of it, so a request that names
     139              : /// `id=` is asking for the intersection and never for the map's whole set.
     140              : /// Returned in the map's own order, which is the pagination order.
     141           52 : pub(crate) fn candidate_ids(map: &Value, params: &HashMap<String, String>) -> Vec<String> {
     142           52 :     let named: Option<std::collections::HashSet<&str>> =
     143           52 :         params.get("id").map(|s| s.split(',').collect());
     144           52 :     map["entityMap"]
     145           52 :         .as_object()
     146           52 :         .map(|o| {
     147           52 :             o.keys()
     148          228 :                 .filter(|k| named.as_ref().is_none_or(|n| n.contains(k.as_str())))
     149           52 :                 .cloned()
     150           52 :                 .collect()
     151           52 :         })
     152           52 :         .unwrap_or_default()
     153           52 : }
     154              : 
     155              : ///
     156              : /// Every store failure here is the caller's: a map the broker could not count
     157              : /// against its ceiling, or could not write, is not a map the client can be
     158              : /// handed the id of (Table 6.3.2-1 InternalError).
     159         2370 : pub(crate) async fn map_put(
     160         2370 :     st: &AppState,
     161         2370 :     tenant: &TenantId,
     162         2370 :     mut doc: Value,
     163         2370 : ) -> Result<(), NgsiError> {
     164         2370 :     let Some(id) = doc.get("id").and_then(Value::as_str).map(str::to_owned) else {
     165            0 :         return Ok(());
     166              :     };
     167         2370 :     clamp_expiry(&mut doc);
     168              :     // The ceiling decides whether a NEW map fits; rewriting a map that is
     169              :     // already stored replaces a row that is already counted, so the paging
     170              :     // path neither lists the tenant's maps nor evicts one per page. A count
     171              :     // the store refuses leaves the ceiling unenforceable, and an unbounded
     172              :     // buffer is not the safer half of that choice: the write is refused.
     173         2370 :     if st.store.get(tenant, Kind::EntityMap, &id).await?.is_none() {
     174         2366 :         let existing = st.store.list(tenant, Kind::EntityMap).await?;
     175         2364 :         if existing.len() >= MAX_MAPS_PER_TENANT {
     176              :             // eviction order is a heuristic — earliest expiresAt string wins
     177           32 :             if let Some(victim) = existing
     178           32 :                 .iter()
     179        16352 :                 .min_by(|a, b| {
     180        16352 :                     a["expiresAt"]
     181        16352 :                         .as_str()
     182        16352 :                         .unwrap_or("")
     183        16352 :                         .cmp(b["expiresAt"].as_str().unwrap_or(""))
     184        16352 :                 })
     185           32 :                 .and_then(|d| d.get("id").and_then(Value::as_str))
     186              :             {
     187           32 :                 st.store.delete(tenant, Kind::EntityMap, victim).await?;
     188            0 :             }
     189         2332 :         }
     190            4 :     }
     191         2368 :     let updated = st
     192         2368 :         .store
     193         2368 :         .mutate(tenant, Kind::EntityMap, &id, |d| {
     194            4 :             *d = doc.clone();
     195            4 :             Ok::<_, std::convert::Infallible>(())
     196            4 :         })
     197         2368 :         .await?
     198         2368 :         .is_some();
     199         2368 :     if !updated {
     200         2364 :         st.store.create(tenant, Kind::EntityMap, &id, doc).await?;
     201            4 :     }
     202         2368 :     Ok(())
     203         2370 : }
     204              : 
     205              : /// 6.4.3.2-1: "the actual expiresAt time of the EntityMap shall be set by
     206              : /// the Context Broker or Context Source, possibly overriding the requested
     207              : /// duration": every writer of a map's expiresAt goes through this. An
     208              : /// absent or unreadable expiry is left alone: 5.5.14 keeps it unusable.
     209         2394 : fn clamp_expiry(doc: &mut Value) {
     210         2394 :     let now = chrono::Utc::now();
     211         2394 :     let ceiling = now
     212         2394 :         .checked_add_signed(chrono::Duration::seconds(MAX_LIFETIME_SECS))
     213         2394 :         .unwrap_or(now);
     214         2394 :     if doc
     215         2394 :         .get("expiresAt")
     216         2394 :         .and_then(Value::as_str)
     217         2394 :         .and_then(dt)
     218         2394 :         .is_some_and(|e| e > ceiling)
     219              :     {
     220         2092 :         if let Some(o) = doc.as_object_mut() {
     221         2092 :             o.insert(
     222         2092 :                 "expiresAt".into(),
     223         2092 :                 json!(ceiling.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
     224         2092 :             );
     225         2092 :         }
     226          302 :     }
     227         2394 : }
     228              : 
     229              : /// 5.14.2.4: set the expiresAt of a map as it is stored now, clamped;
     230              : /// `false` when the map is gone (a deleted map is not resurrected).
     231           24 : pub(crate) async fn map_set_expiry(
     232           24 :     st: &AppState,
     233           24 :     tenant: &TenantId,
     234           24 :     id: &str,
     235           24 :     expires: &str,
     236           24 : ) -> Result<bool, NgsiError> {
     237           24 :     let mut want = json!({"expiresAt": expires});
     238           24 :     clamp_expiry(&mut want);
     239           24 :     Ok(st
     240           24 :         .store
     241           24 :         .mutate(tenant, Kind::EntityMap, id, |d| {
     242           20 :             if let Some(o) = d.as_object_mut() {
     243           20 :                 if let Some(e) = want.get("expiresAt") {
     244           20 :                     o.insert("expiresAt".into(), e.clone());
     245           20 :                 }
     246            0 :             }
     247           20 :             Ok::<_, std::convert::Infallible>(())
     248           20 :         })
     249           24 :         .await?
     250           24 :         .is_some())
     251           24 : }
     252              : 
     253              : /// 5.5.9.3 "Entities not or no longer fitting the query shall be removed
     254              : /// from the Entity map during pagination": remove the `stale` ids a page
     255              : /// visited, and nothing else, from the map as it is stored NOW. A request
     256              : /// that read the map before an update or a delete writes back only its own
     257              : /// removals, so a new expiresAt survives and a deleted map stays deleted.
     258           58 : pub(crate) async fn map_prune(
     259           58 :     st: &AppState,
     260           58 :     tenant: &TenantId,
     261           58 :     id: &str,
     262           58 :     stale: &[String],
     263           58 : ) -> Result<(), NgsiError> {
     264           58 :     if stale.is_empty() {
     265           42 :         return Ok(());
     266           16 :     }
     267           16 :     st.store
     268           16 :         .mutate(tenant, Kind::EntityMap, id, |d| {
     269           12 :             if let Some(emap) = d.get_mut("entityMap").and_then(Value::as_object_mut) {
     270           12 :                 for k in stale {
     271           12 :                     emap.remove(k);
     272           12 :                 }
     273            0 :             }
     274           12 :             Ok::<_, std::convert::Infallible>(())
     275           12 :         })
     276           16 :         .await?;
     277           16 :     Ok(())
     278           58 : }
     279              : 
     280              : /// 5.14.3.4: "If the NGSI-LD endpoint does not know about a matching EntityMap
     281              : /// for the EntityMap ID, then an error of type ResourceNotFound shall be
     282              : /// raised." What the endpoint knows about is what [`map_get`] serves, so the
     283              : /// delete reads through it: an expired map is beyond access (5.5.14) for the
     284              : /// retrieve and for the delete alike; the expired row is left for
     285              : /// `sweep_expired_maps` to collect.
     286          116 : pub(crate) async fn map_delete(
     287          116 :     st: &AppState,
     288          116 :     tenant: &TenantId,
     289          116 :     id: &str,
     290          116 : ) -> Result<bool, NgsiError> {
     291          116 :     if map_get(st, tenant, id).await?.is_none() {
     292            4 :         return Ok(false);
     293          112 :     }
     294          112 :     st.store.delete(tenant, Kind::EntityMap, id).await
     295          116 : }
     296              : 
     297              : /// Parse an ISO 8601 duration (entityMapLifetime, Table 6.4.3.2-1) to whole
     298              : /// seconds; years/months are approximated (365/30 days), fractions rejected.
     299          174 : pub(crate) fn iso8601_secs(s: &str) -> Option<i64> {
     300              :     // A lifetime is a span, so the calendar components are weighed at their
     301              :     // nominal length — a year 365 days, a month 30 — and a fractional or
     302              :     // absent component has no whole-second span to weigh.
     303          174 :     let d = antares_model::parse_iso_duration(s).filter(|d| d.whole && !d.empty)?;
     304           68 :     [
     305           68 :         (d.years, 31_536_000),
     306           68 :         (d.months, 2_592_000),
     307           68 :         (d.weeks, 604_800),
     308           68 :         (d.days, 86_400),
     309           68 :         (d.hours, 3_600),
     310           68 :         (d.minutes, 60),
     311           68 :         (d.seconds, 1),
     312           68 :     ]
     313           68 :     .into_iter()
     314          452 :     .try_fold(0i64, |acc, (n, per)| {
     315          452 :         acc.checked_add((n as i64).checked_mul(per)?)
     316          452 :     })
     317          174 : }
     318              : 
     319              : /// The expiresAt the broker assigns (5.2.39): now + suggested lifetime,
     320              : /// bounded by the broker's ceiling; the default applies when none is given.
     321          276 : pub(crate) fn expires_at(params: &HashMap<String, String>) -> Result<String, NgsiError> {
     322          276 :     let secs = match params.get("entityMapLifetime") {
     323           22 :         Some(d) => iso8601_secs(d)
     324           22 :             .ok_or_else(|| {
     325           10 :                 NgsiError::BadRequestData(format!(
     326           10 :                     "entityMapLifetime is not an ISO 8601 duration: {d:?}"
     327           10 :                 ))
     328           10 :             })?
     329              :             // A zero or negative suggestion would answer 201 with a map that
     330              :             // 5.5.14 already forbids anyone from accessing, so the broker
     331              :             // floor applies as well as the ceiling.
     332           12 :             .clamp(1, MAX_LIFETIME_SECS),
     333          254 :         None => DEFAULT_LIFETIME_SECS,
     334              :     };
     335              :     // `secs` is clamped to MAX_LIFETIME_SECS above, far inside chrono's range.
     336          266 :     let now = chrono::Utc::now();
     337          266 :     Ok(now
     338          266 :         .checked_add_signed(chrono::Duration::seconds(secs))
     339          266 :         .unwrap_or(now)
     340          266 :         .to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
     341          276 : }
     342              : 
     343              : /// 5.14.1.4 / 5.14.2.4 / 5.14.3.4: every /entityMaps/{id} method opens the
     344              : /// same way — the tenant it runs in, `local` (6.3.18) as the only parameter
     345              : /// this resource takes, and an id that must be a valid URI before the store
     346              : /// is touched at all.
     347          284 : pub(crate) fn open_map(
     348          284 :     params: &HashMap<String, String>,
     349          284 :     headers: &HeaderMap,
     350          284 :     id: &str,
     351          284 : ) -> ApiResult<TenantId> {
     352          284 :     let tenant = tenant_from(headers)?;
     353          284 :     check_params(params, &["local"])?;
     354          284 :     map_id_check(id)?;
     355          272 :     Ok(tenant)
     356          284 : }
     357              : 
     358              : /// 5.14.1.4 / 5.14.3.4: "If the EntityMap id is not present or it is not a
     359              : /// valid URI, then an error of type BadRequestData shall be raised."
     360          326 : pub(crate) fn map_id_check(id: &str) -> Result<(), NgsiError> {
     361          326 :     antares_model::EntityId::new(id)
     362          326 :         .map(|_| ())
     363          326 :         .map_err(|_| NgsiError::BadRequestData(format!("EntityMap id is not a valid URI: {id:?}")))
     364          326 : }
     365              : 
     366              : /// Table 5.2.39-2 on the way in: a returned EntityMap's `entityMap` is "a
     367              : /// set of key-value pairs whose keys shall be strings representing Entity
     368              : /// ids", so a key from a Context Source is checked before it becomes a key
     369              : /// of the map this broker stores under its own id and serves from
     370              : /// `/entityMaps/{id}`. Per key, not per peer: a source that names one
     371              : /// unusable id still contributes its usable ones. `@none` fails the check
     372              : /// with everything else, which is what it deserves here — it is the PEER's
     373              : /// "held locally" marker (5.2.39) and stands for no Entity id on this side.
     374              : /// `cap` is the ceiling the local half of the map already carries
     375              : /// (`st.max_limit`), so no one Context Source is larger than the broker.
     376           14 : fn peer_entity_ids(remote: &Value, cap: usize) -> Vec<&String> {
     377           14 :     remote
     378           14 :         .get("entityMap")
     379           14 :         .and_then(Value::as_object)
     380           14 :         .into_iter()
     381           14 :         .flat_map(serde_json::Map::keys)
     382         2020 :         .filter(|k| antares_model::EntityId::new(k).is_ok())
     383           14 :         .take(cap)
     384           14 :         .collect()
     385           14 : }
     386              : 
     387              : /// 5.14.4.4 and 5.14.5.4 end the same way: "The mapping between the Context
     388              : /// Source Registration and the EntityMap Id is added to the linkedMaps
     389              : /// element of the local EntityMap and for the Entity ids included in the
     390              : /// returned Entity Maps a mapping to the Context Source Registration is added
     391              : /// to the entityMap element of the local EntityMap. The local EntityMap is
     392              : /// stored and made accessible based on its identifier." The two operations
     393              : /// differ only in which registration operation they ask for and which peer
     394              : /// resource carries it.
     395          236 : pub(crate) async fn merge_and_store_map(
     396          236 :     st: &AppState,
     397          236 :     tenant: &TenantId,
     398          236 :     headers: &HeaderMap,
     399          236 :     ctx: &antares_jsonld::Context,
     400          236 :     params: &HashMap<String, String>,
     401          236 :     temporal: bool,
     402          236 :     mut emap: Map<String, Value>,
     403          236 : ) -> ApiResult<Value> {
     404          236 :     let (op, path) = if temporal {
     405           50 :         ("createEntityMapQueryTemporal", "temporal/entityMaps")
     406              :     } else {
     407          186 :         ("createEntityMapQueryEntity", "entityMaps")
     408              :     };
     409          236 :     let mut linked = Map::new();
     410              :     // 5.5.13 local=true: no Context Source Registration is considered, so
     411              :     // nothing merges in and linkedMaps stays empty.
     412          236 :     if params.get("local").map(String::as_str) != Some("true") {
     413          224 :         let split = params.get("splitEntities").map(String::as_str) == Some("true");
     414           14 :         for (reg_id, remote) in
     415          224 :             crate::federation::fed_entity_maps(st, tenant, headers, ctx, params, split, op, path)
     416          224 :                 .await?
     417              :         {
     418         2012 :             for eid in peer_entity_ids(&remote, st.max_limit) {
     419         2012 :                 if let Some(a) = emap
     420         2012 :                     .entry(eid.clone())
     421         2012 :                     .or_insert_with(|| json!([]))
     422         2012 :                     .as_array_mut()
     423         2012 :                 {
     424         2012 :                     a.push(json!(reg_id.clone()));
     425         2012 :                 }
     426              :             }
     427              :             // Table 5.2.39-1 restricts an EntityMap id to a valid URI, and
     428              :             // 5.14.1.4 refuses one that is not from a client. The peer's id
     429              :             // travels back out as the `NGSILD-EntityMap` header of every
     430              :             // later forwarded page (`federation::map_gate`), so it is held
     431              :             // to the same rule; without a usable id the registration simply
     432              :             // carries no linked map and the peer re-runs its own query.
     433           14 :             match remote.get("id").and_then(Value::as_str) {
     434           14 :                 Some(mid) if map_id_check(mid).is_ok() => {
     435           12 :                     linked.insert(reg_id, json!(mid));
     436           12 :                 }
     437            2 :                 _ => {}
     438              :             }
     439              :         }
     440           12 :     }
     441          236 :     let mut doc = json!({
     442          236 :         "id": format!("urn:ngsi-ld:entitymap:{}", uuid::Uuid::new_v4()),
     443          236 :         "type": "EntityMap",
     444          236 :         "expiresAt": expires_at(params)?,
     445          230 :         "entityMap": Value::Object(emap),
     446          230 :         "linkedMaps": Value::Object(linked),
     447              :     });
     448          230 :     stamp_subject(&mut doc, tenant, headers);
     449          230 :     map_put(st, tenant, doc.clone()).await?;
     450          228 :     Ok(doc)
     451          236 : }
     452              : 
     453              : /// 5.7.1.4 / 5.7.3.4: the EntityMap created for a single-Entity retrieve —
     454              : /// its one entry lists "@none" when Attribute data is held locally plus
     455              : /// every matching Context Source Registration supporting the retrieve
     456              : /// operation ("only the retrieved Entity Map shall be used to determine
     457              : /// which Context Source Registrations match the Entity ID").
     458              : #[allow(clippy::too_many_arguments)] // one param per 5.7.1.4 input
     459           24 : pub(crate) async fn build_retrieve_map(
     460           24 :     st: &AppState,
     461           24 :     tenant: &TenantId,
     462           24 :     ctx: &antares_jsonld::Context,
     463           24 :     headers: &HeaderMap,
     464           24 :     id: &str,
     465           24 :     params: &HashMap<String, String>,
     466           24 :     temporal: bool,
     467           24 :     local_held: bool,
     468           24 : ) -> Result<Value, NgsiError> {
     469           24 :     let mut srcs: Vec<Value> = Vec::new();
     470           24 :     if local_held {
     471           24 :         srcs.push(json!("@none"));
     472           24 :     }
     473           24 :     if crate::federation::active(params) {
     474           22 :         let spec = crate::registry::CsrSpec {
     475           22 :             ids: Some(vec![id.to_owned()]),
     476           22 :             ..Default::default()
     477           22 :         };
     478           22 :         for reg in crate::federation::matching_regs(st, tenant, &spec, ctx, headers).await? {
     479            0 :             let ok = if temporal {
     480            0 :                 reg.supports("retrieveTemporal")
     481              :             } else {
     482            0 :                 reg.read_op().is_some()
     483              :             };
     484            0 :             if ok {
     485            0 :                 srcs.push(json!(reg.reg_id));
     486            0 :             }
     487              :         }
     488            2 :     }
     489           24 :     let mut emap = Map::new();
     490           24 :     if !srcs.is_empty() {
     491           24 :         emap.insert(id.to_owned(), Value::Array(srcs));
     492           24 :     }
     493           24 :     let mut doc = json!({
     494           24 :         "id": format!("urn:ngsi-ld:entitymap:{}", uuid::Uuid::new_v4()),
     495           24 :         "type": "EntityMap",
     496           24 :         "expiresAt": expires_at(params)?,
     497           24 :         "entityMap": Value::Object(emap),
     498           24 :         "linkedMaps": {},
     499              :     });
     500           24 :     stamp_subject(&mut doc, tenant, headers);
     501           24 :     map_put(st, tenant, doc.clone()).await?;
     502           24 :     Ok(doc)
     503           24 : }
     504              : 
     505              : /// The NGSILD-EntityMap response header: the resource URI of the map that
     506              : /// determined the sources of this response (6.3.17).
     507           30 : pub(crate) fn set_map_header(resp: &mut Response, mid: &str) {
     508           30 :     if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{mid}").parse() {
     509           30 :         resp.headers_mut().insert("NGSILD-EntityMap", v);
     510           30 :     }
     511           30 : }
     512              : 
     513              : /// 5.7.1.4 / 5.7.3.4 EntityMap usage on a single-Entity retrieve: a supplied
     514              : /// NGSILD-EntityMap location is retrieved and, if live, is the only source
     515              : /// used to determine which registrations match; an unknown or expired
     516              : /// reference — or the `entityMap=true` flag — creates a new map, whose
     517              : /// location is returned in the NGSILD-EntityMap response header. The two
     518              : /// clauses word that rule identically and part company only over what
     519              : /// "held locally" reads and which operation a registration must support,
     520              : /// and both of those follow from `temporal` — so the rule is read once
     521              : /// here and the retrieve itself is the caller's `inner`.
     522         3264 : pub(crate) async fn retrieve_with_map<F, Fut>(
     523         3264 :     st: &AppState,
     524         3264 :     id: &str,
     525         3264 :     params: &HashMap<String, String>,
     526         3264 :     headers: &HeaderMap,
     527         3264 :     temporal: bool,
     528         3264 :     inner: F,
     529         3264 : ) -> ApiResult<Response>
     530         3264 : where
     531         3264 :     F: Fn(Option<Value>) -> Fut,
     532         3264 :     Fut: std::future::Future<Output = ApiResult<Response>>,
     533         3264 : {
     534         3264 :     let tenant = tenant_from(headers)?;
     535         3264 :     let map_ref = single_header(headers, "NGSILD-EntityMap")?
     536         3262 :         .map(|r| r.rsplit('/').next().unwrap_or(&r).to_owned());
     537         3262 :     let existing = match map_ref.as_deref() {
     538           12 :         Some(mid) => map_if_accessible(st, &tenant, headers, mid).await,
     539         3250 :         None => None,
     540              :     };
     541         3262 :     if let Some(map) = existing {
     542            6 :         let mut resp = inner(Some(map)).await?;
     543            6 :         set_map_header(&mut resp, &map_ref.unwrap_or_default());
     544            6 :         return Ok(resp);
     545         3256 :     }
     546         3256 :     let want_map = map_ref.is_some() || params.get("entityMap").map(String::as_str) == Some("true");
     547         3256 :     let mut resp = inner(None).await?;
     548         2940 :     if want_map && resp.status().is_success() {
     549           24 :         let ctx = request_context(&st.loader, headers).await?;
     550           24 :         let local_held = if temporal {
     551            6 :             st.temporal
     552            6 :                 .get_temporal(
     553            6 :                     &tenant,
     554            6 :                     id,
     555            6 :                     &antares_store::filter::TemporalFilter::default(),
     556            6 :                 )
     557            6 :                 .await?
     558            6 :                 .is_some()
     559              :         } else {
     560           18 :             st.store.get(&tenant, Kind::Entity, id).await?.is_some()
     561              :         };
     562           24 :         let map = build_retrieve_map(st, &tenant, &ctx, headers, id, params, temporal, local_held)
     563           24 :             .await?;
     564           24 :         if let Some(mid) = map.get("id").and_then(Value::as_str) {
     565           24 :             set_map_header(&mut resp, mid);
     566           24 :         }
     567         2916 :     }
     568         2940 :     Ok(resp)
     569         3264 : }
     570              : 
     571              : /// 201 + the EntityMap body + the NGSILD-EntityMap header carrying the
     572              : /// resource URI of the created map (6.34.3.1 / 6.35.3.1).
     573          170 : pub(crate) fn created_response(
     574          170 :     doc: Value,
     575          170 :     ctx: &antares_jsonld::Context,
     576          170 :     accept: Accept,
     577          170 :     tenant: &TenantId,
     578          170 : ) -> Response {
     579          170 :     let uri = format!(
     580              :         "/ngsi-ld/v1/entityMaps/{}",
     581          170 :         doc.get("id").and_then(Value::as_str).unwrap_or_default()
     582              :     );
     583          170 :     let mut doc = doc;
     584          170 :     crate::policy::strip_internal(&mut doc);
     585          170 :     let mut resp = respond(StatusCode::CREATED, doc, ctx, accept, tenant);
     586          170 :     if let Ok(v) = uri.parse() {
     587          170 :         resp.headers_mut().insert("NGSILD-EntityMap", v);
     588          170 :     }
     589          170 :     resp
     590          170 : }
     591              : 
     592              : #[cfg(test)]
     593              : mod tests {
     594              :     use super::*;
     595              :     use antares_store::Kind;
     596              :     use serde_json::json;
     597              : 
     598              :     /// `map_get` with its store failure unwrapped: these tests drive a
     599              :     /// working store, where a refusal would be the test's own bug.
     600           56 :     async fn map_read(st: &AppState, tenant: &TenantId, id: &str) -> Option<Value> {
     601           56 :         map_get(st, tenant, id).await.expect("the store answers")
     602           56 :     }
     603              : 
     604              :     /// 5.5.14 with RFC 9110 §9.2.1: reading a map the broker will not serve
     605              :     /// must not write. The row survives the read, and the sweep is what
     606              :     /// frees it.
     607              :     #[tokio::test]
     608            4 :     async fn an_expired_map_is_refused_by_a_read_that_writes_nothing() {
     609            4 :         let st = crate::wired_state("antares-map-sweep").await;
     610            4 :         let t = TenantId::default();
     611            4 :         let id = "urn:ngsi-ld:EntityMap:stale";
     612            4 :         st.store
     613            4 :             .create(
     614            4 :                 &t,
     615            4 :                 Kind::EntityMap,
     616            4 :                 id,
     617            4 :                 json!({"id": id, "expiresAt": "2000-01-01T00:00:00Z"}),
     618            4 :             )
     619            4 :             .await
     620            4 :             .expect("seed");
     621              : 
     622            4 :         assert!(map_read(&st, &t, id).await.is_none(), "5.5.14: not served");
     623            4 :         assert!(
     624            4 :             st.store
     625            4 :                 .get(&t, Kind::EntityMap, id)
     626            4 :                 .await
     627            4 :                 .expect("store")
     628            4 :                 .is_some(),
     629              :             "the read deleted the row: a GET must be safe (RFC 9110 9.2.1)"
     630              :         );
     631              : 
     632            4 :         assert_eq!(sweep_expired_maps(&st, &t).await, 1, "the sweep reaps it");
     633            4 :         assert!(
     634            4 :             st.store
     635            4 :                 .get(&t, Kind::EntityMap, id)
     636            4 :                 .await
     637            4 :                 .expect("store")
     638            4 :                 .is_none(),
     639            4 :             "the sweep left the row behind"
     640            4 :         );
     641            4 :     }
     642              : 
     643              :     /// The sweep removes exactly what a read refuses — never a map still in
     644              :     /// its lifetime.
     645              :     #[tokio::test]
     646            4 :     async fn the_sweep_keeps_every_map_a_read_would_still_serve() {
     647            4 :         let st = crate::wired_state("antares-map-sweep-live").await;
     648            4 :         let t = TenantId::default();
     649            4 :         let live = "urn:ngsi-ld:EntityMap:live";
     650            4 :         st.store
     651            4 :             .create(
     652            4 :                 &t,
     653            4 :                 Kind::EntityMap,
     654            4 :                 live,
     655            4 :                 json!({"id": live, "expiresAt": "2999-01-01T00:00:00Z"}),
     656            4 :             )
     657            4 :             .await
     658            4 :             .expect("seed");
     659            4 :         assert_eq!(
     660            4 :             sweep_expired_maps(&st, &t).await,
     661              :             0,
     662              :             "a live map was reaped"
     663              :         );
     664            4 :         assert!(map_read(&st, &t, live).await.is_some(), "still served");
     665            4 :     }
     666              : 
     667              :     /// Table 6.4.3.2-1: entityMapLifetime is an ISO 8601 duration.
     668              :     #[test]
     669            4 :     fn clause_5_14_4_lifetime_parse() {
     670            4 :         assert_eq!(iso8601_secs("PT1H"), Some(3600));
     671            4 :         assert_eq!(iso8601_secs("PT90S"), Some(90));
     672            4 :         assert_eq!(iso8601_secs("P1DT2H3M4S"), Some(93784));
     673            4 :         assert_eq!(iso8601_secs("P2W"), Some(1_209_600));
     674              :         // invalid shapes are rejected (→ 400 at the handler)
     675           28 :         for bad in ["", "P", "PT", "1H", "PT1X", "PT1.5S", "PT1"] {
     676           28 :             assert_eq!(iso8601_secs(bad), None, "{bad:?}");
     677              :         }
     678            4 :     }
     679              : 
     680              :     /// Table 6.4.3.2-1: entityMapLifetime arrives on the query string, so
     681              :     /// the parser is attacker-facing — every hostile shape must return None
     682              :     /// (a 400) rather than panic, wrap or saturate.
     683              :     #[test]
     684            4 :     fn clause_5_14_4_lifetime_hostile_inputs() {
     685           64 :         for bad in [
     686            4 :             "P-1D",                    // negative component
     687            4 :             "-P1D",                    // negative duration
     688            4 :             "P+1D",                    // signed component
     689            4 :             "p1d",                     // lower case designators
     690            4 :             " PT1H",                   // leading whitespace
     691            4 :             "PT1H ",                   // trailing whitespace
     692            4 :             "P1DT",                    // empty time part
     693            4 :             "PT99999999999999999999S", // digit run past i64
     694            4 :             "P9999999999999Y",         // multiplication overflow
     695            4 :             "P92233720368547758S",     // addition overflow after scaling
     696            4 :             "PT1H1",                   // trailing digits, no designator
     697            4 :             "P١D",                     // non-ASCII digit
     698            4 :             "P1D\u{0}",                // embedded NUL
     699            4 :             "PT,5S",                   // comma fraction
     700            4 :             "P1S",                     // time designator in the date part
     701            4 :             "PT1D",                    // date designator in the time part
     702            4 :         ] {
     703           64 :             assert_eq!(iso8601_secs(bad), None, "{bad:?} must not parse");
     704              :         }
     705              :         // the whole i64 range is walked without panicking
     706            4 :         assert_eq!(iso8601_secs(&format!("PT{}S", i64::MAX)), Some(i64::MAX));
     707            4 :         assert_eq!(iso8601_secs(&format!("PT{}S", u64::MAX)), None);
     708            4 :         assert_eq!(iso8601_secs("PT0S"), Some(0));
     709            4 :     }
     710              : 
     711           16 :     fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
     712           16 :         pairs
     713           16 :             .iter()
     714           16 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
     715           16 :             .collect()
     716           16 :     }
     717              : 
     718              :     /// 6.4.3.2-1: "the actual expiresAt time of the EntityMap shall be set by
     719              :     /// the Context Broker or Context Source, possibly overriding the
     720              :     /// requested duration" — the client suggestion is bounded above by the
     721              :     /// broker ceiling and below by a lifetime the map can actually be used
     722              :     /// for; an unparseable duration is BadRequestData.
     723              :     #[test]
     724            4 :     fn clause_5_14_4_expires_at_is_broker_bounded() {
     725            4 :         let now = chrono::Utc::now();
     726           12 :         let at = |p: &[(&str, &str)]| {
     727           12 :             let s = expires_at(&params(p)).expect("expiry");
     728           12 :             dt(&s).expect("RFC 3339 expiry")
     729           12 :         };
     730            4 :         let default = at(&[]);
     731            4 :         assert!(
     732            4 :             (default - now).num_seconds() >= DEFAULT_LIFETIME_SECS - 5
     733            4 :                 && (default - now).num_seconds() <= DEFAULT_LIFETIME_SECS + 5,
     734              :             "no suggestion → the default lifetime"
     735              :         );
     736            4 :         let capped = at(&[("entityMapLifetime", "P30D")]);
     737            4 :         assert!(
     738            4 :             (capped - now).num_seconds() <= MAX_LIFETIME_SECS,
     739              :             "a client cannot exceed the broker ceiling"
     740              :         );
     741            4 :         let zero = at(&[("entityMapLifetime", "PT0S")]);
     742            4 :         assert!(
     743            4 :             zero > now,
     744              :             "a zero lifetime would return 201 for a map that is already \
     745              :              unusable (5.5.14): {zero}"
     746              :         );
     747            4 :         match expires_at(&params(&[("entityMapLifetime", "yesterday")])) {
     748            4 :             Err(NgsiError::BadRequestData(_)) => {}
     749            0 :             other => panic!("an invalid duration must be BadRequestData: {other:?}"),
     750              :         }
     751            4 :     }
     752              : 
     753              :     /// 5.14: EntityMaps are per-tenant resources — an EntityMap created
     754              :     /// under one tenant is invisible and undeletable from another (4.14
     755              :     /// multi-tenancy: "an NGSI-LD system shall behave as if the tenants were
     756              :     /// separate systems").
     757              :     #[tokio::test]
     758            4 :     async fn clause_5_14_maps_are_tenant_scoped() {
     759            4 :         let st = AppState::new("antares-em-unit".into());
     760            4 :         let a = TenantId::new("alpha").expect("tenant");
     761            4 :         let b = TenantId::new("beta").expect("tenant");
     762            4 :         let id = "urn:ngsi-ld:entitymap:t1";
     763            4 :         map_put(&st, &a, live_map(id)).await.expect("stored");
     764            4 :         assert!(map_read(&st, &a, id).await.is_some());
     765            4 :         assert!(
     766            4 :             map_read(&st, &b, id).await.is_none(),
     767              :             "another tenant must not read the map"
     768              :         );
     769            4 :         assert!(
     770            4 :             !map_delete(&st, &b, id).await.expect("delete"),
     771              :             "another tenant must not delete the map"
     772              :         );
     773            4 :         assert!(
     774            4 :             map_read(&st, &a, id).await.is_some(),
     775              :             "the owner still has its map"
     776              :         );
     777            4 :         assert!(map_delete(&st, &a, id).await.expect("delete"));
     778            4 :         assert!(map_read(&st, &a, id).await.is_none());
     779            4 :     }
     780              : 
     781              :     /// 5.5.9.3 + 5.14.2.4: a page's prune lands after an
     782              :     /// update and a delete that ran while the page was read. The update's
     783              :     /// expiresAt survives, only the visited stale id goes, and a map deleted
     784              :     /// mid-walk stays deleted.
     785              :     #[tokio::test]
     786            4 :     async fn clause_5_5_9_3_prune_keeps_a_concurrent_update_and_delete() {
     787            4 :         let st = AppState::new("antares-em-prune".into());
     788            4 :         let t = TenantId::default();
     789            4 :         let id = "urn:ngsi-ld:entitymap:p1";
     790            4 :         let mut doc = live_map(id);
     791            4 :         doc["entityMap"] = json!({"urn:a": ["@none"], "urn:b": ["@none"]});
     792            4 :         map_put(&st, &t, doc).await.expect("stored");
     793            4 :         let read_by_page = map_read(&st, &t, id).await.expect("map");
     794            4 :         let later = (chrono::Utc::now() + chrono::Duration::seconds(3000))
     795            4 :             .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
     796            4 :         assert!(map_set_expiry(&st, &t, id, &later).await.expect("update"));
     797            4 :         assert_ne!(read_by_page["expiresAt"], json!(later));
     798            4 :         map_prune(&st, &t, id, &["urn:a".to_owned()])
     799            4 :             .await
     800            4 :             .expect("prune");
     801            4 :         let now = map_read(&st, &t, id).await.expect("map");
     802            4 :         assert_eq!(now["expiresAt"], json!(later), "the prune undid the update");
     803            4 :         assert_eq!(now["entityMap"], json!({"urn:b": ["@none"]}));
     804              : 
     805            4 :         assert!(map_delete(&st, &t, id).await.expect("delete"));
     806            4 :         map_prune(&st, &t, id, &["urn:b".to_owned()])
     807            4 :             .await
     808            4 :             .expect("prune");
     809            4 :         assert!(
     810            4 :             st.store
     811            4 :                 .get(&t, Kind::EntityMap, id)
     812            4 :                 .await
     813            4 :                 .expect("store")
     814            4 :                 .is_none(),
     815              :             "the prune resurrected a deleted map"
     816              :         );
     817            4 :         assert!(
     818            4 :             !map_set_expiry(&st, &t, id, &later).await.expect("update"),
     819              :             "an update resurrected a deleted map"
     820              :         );
     821            4 :         assert!(st
     822            4 :             .store
     823            4 :             .get(&t, Kind::EntityMap, id)
     824            4 :             .await
     825            4 :             .expect("store")
     826            4 :             .is_none());
     827            4 :     }
     828              : 
     829         2116 :     fn live_map(id: &str) -> Value {
     830         2116 :         json!({
     831         2116 :             "id": id,
     832         2116 :             "type": "EntityMap",
     833         2116 :             "expiresAt": (chrono::Utc::now() + chrono::Duration::seconds(600))
     834         2116 :                 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
     835         2116 :             "entityMap": {},
     836         2116 :             "linkedMaps": {},
     837              :         })
     838         2116 :     }
     839              : 
     840              :     /// 5.5.14: an expired EntityMap "cannot be accessed" — it is never
     841              :     /// served, and a map whose expiry cannot be read is treated the same way
     842              :     /// rather than living forever.
     843              :     #[tokio::test]
     844            4 :     async fn clause_5_5_14_expired_maps_are_never_served() {
     845            4 :         let st = AppState::new("antares-em-exp".into());
     846            4 :         let t = TenantId::default();
     847            4 :         let mut past = live_map("urn:ngsi-ld:entitymap:past");
     848            4 :         past["expiresAt"] = json!("2020-01-01T00:00:00.000Z");
     849            4 :         map_put(&st, &t, past).await.expect("stored");
     850            4 :         assert!(map_read(&st, &t, "urn:ngsi-ld:entitymap:past")
     851            4 :             .await
     852            4 :             .is_none());
     853            4 :         assert!(
     854            4 :             st.store
     855            4 :                 .get(&t, Kind::EntityMap, "urn:ngsi-ld:entitymap:past")
     856            4 :                 .await
     857            4 :                 .expect("store")
     858            4 :                 .is_some(),
     859              :             "the read pruned the row: reading must not write"
     860              :         );
     861           12 :         for (id, expiry) in [
     862            4 :             ("urn:ngsi-ld:entitymap:none", None),
     863            4 :             ("urn:ngsi-ld:entitymap:junk", Some(json!("whenever"))),
     864            4 :             ("urn:ngsi-ld:entitymap:num", Some(json!(0))),
     865            4 :         ] {
     866           12 :             let mut doc = live_map(id);
     867           12 :             match expiry {
     868            8 :                 Some(v) => doc["expiresAt"] = v,
     869            4 :                 None => {
     870            4 :                     doc.as_object_mut().expect("object").remove("expiresAt");
     871            4 :                 }
     872            4 :             }
     873           12 :             map_put(&st, &t, doc).await.expect("stored");
     874           12 :             assert!(
     875           12 :                 map_read(&st, &t, id).await.is_none(),
     876            4 :                 "{id} has no readable expiry and must not be served"
     877            4 :             );
     878            4 :         }
     879            4 :         // What no read will serve, the sweep frees: the four seeded above.
     880            4 :         assert_eq!(sweep_expired_maps(&st, &t).await, 4);
     881            4 :     }
     882              : 
     883              :     /// 5.14.1.1 storage: every buffer is bounded — the per-tenant EntityMap
     884              :     /// registry has a ceiling, and filling it evicts rather than growing.
     885              :     #[tokio::test]
     886            4 :     async fn clause_5_14_1_map_registry_is_bounded() {
     887            4 :         let st = AppState::new("antares-em-cap".into());
     888            4 :         let t = TenantId::default();
     889         2080 :         for i in 0..MAX_MAPS_PER_TENANT + 8 {
     890         2080 :             let mut doc = live_map(&format!("urn:ngsi-ld:entitymap:{i:04}"));
     891              :             // earliest expiry first, so the eviction victim is deterministic
     892         2080 :             doc["expiresAt"] = json!(format!("2099-01-01T00:00:{:02}.000Z", i % 60));
     893         2080 :             map_put(&st, &t, doc).await.expect("stored");
     894         2080 :             assert!(
     895         2080 :                 st.store
     896         2080 :                     .list(&t, Kind::EntityMap)
     897         2080 :                     .await
     898         2080 :                     .expect("list")
     899         2080 :                     .len()
     900              :                     <= MAX_MAPS_PER_TENANT,
     901              :                 "the registry exceeded its ceiling at {i}"
     902              :             );
     903              :         }
     904              :         // re-storing a known id is an update, never an eviction
     905            4 :         let before = st
     906            4 :             .store
     907            4 :             .list(&t, Kind::EntityMap)
     908            4 :             .await
     909            4 :             .expect("list")
     910            4 :             .len();
     911            4 :         let known = st.store.list(&t, Kind::EntityMap).await.expect("list")[0]["id"]
     912            4 :             .as_str()
     913            4 :             .expect("id")
     914            4 :             .to_owned();
     915            4 :         map_put(&st, &t, live_map(&known)).await.expect("stored");
     916            4 :         assert_eq!(
     917            4 :             st.store
     918            4 :                 .list(&t, Kind::EntityMap)
     919            4 :                 .await
     920            4 :                 .expect("list")
     921            4 :                 .len(),
     922            4 :             before
     923            4 :         );
     924            4 :     }
     925              : 
     926              :     /// 5.5.14 + Table 6.4.3.2-1: "the actual expiresAt time of the EntityMap
     927              :     /// shall be set by the Context Broker or Context Source, possibly
     928              :     /// overriding the requested duration" — the 5.14.2.4 update path writes a
     929              :     /// client-chosen instant, so the broker ceiling binds when the map is
     930              :     /// stored, not only when it is created.
     931              :     #[tokio::test]
     932            4 :     async fn clause_5_5_14_stored_expiry_never_exceeds_the_broker_ceiling() {
     933            4 :         let st = AppState::new("antares-em-clamp".into());
     934            4 :         let t = TenantId::default();
     935            4 :         let ceiling = chrono::Utc::now() + chrono::Duration::seconds(MAX_LIFETIME_SECS);
     936            4 :         let far = "urn:ngsi-ld:entitymap:far";
     937            4 :         let mut doc = live_map(far);
     938            4 :         doc["expiresAt"] = json!("2099-01-01T00:00:00.000Z");
     939            4 :         map_put(&st, &t, doc).await.expect("stored");
     940            4 :         let stored = map_read(&st, &t, far)
     941            4 :             .await
     942            4 :             .expect("a clamped map is still live");
     943            4 :         let at = dt(stored["expiresAt"].as_str().expect("expiresAt")).expect("RFC 3339 expiry");
     944            4 :         assert!(
     945            4 :             at <= ceiling + chrono::Duration::seconds(5),
     946              :             "a client cannot pin an EntityMap past the broker ceiling: {at}"
     947              :         );
     948            4 :         assert!(at > chrono::Utc::now(), "the map stays usable: {at}");
     949              :         // an expiry inside the ceiling is stored verbatim, not rewritten
     950            4 :         let near = "urn:ngsi-ld:entitymap:near";
     951            4 :         let doc = live_map(near);
     952            4 :         let want = doc["expiresAt"].clone();
     953            4 :         map_put(&st, &t, doc).await.expect("stored");
     954            4 :         assert_eq!(
     955            4 :             map_read(&st, &t, near).await.expect("live")["expiresAt"],
     956            4 :             want
     957            4 :         );
     958            4 :     }
     959              : }
        

Generated by: LCOV version 2.0-1