LCOV - code coverage report
Current view: top level - antares-api/src - snapshots.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.2 % 1887 1834
Test Date: 2026-11-02 13:21:27 Functions: 62.6 % 647 405

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! 5.16 Snapshots (optional API group; resources 6.36 /snapshots,
       3              : //! 6.37 /snapshots/{id}, 6.38 /snapshots/{id}/clone; scoping 6.3.22).
       4              : //!
       5              : //! A Snapshot freezes the results of a set of queries (5.2.41) into an
       6              : //! isolated copy on which Core + Temporal API operations run implicitly
       7              : //! local (5.5.15). Implementation shape: each snapshot owns a synthetic
       8              : //! internal tenant ("snap-…"); the 6.3.22 NGSILD-Snapshot header is resolved
       9              : //! by a middleware that rewrites the request's tenant, so every existing
      10              : //! Core/Temporal handler serves snapshot content unchanged — and, because
      11              : //! no registrations exist under the synthetic tenant, all operations are
      12              : //! naturally local.
      13              : //!
      14              : //! Snapshot metadata lives in the store (Kind::Snapshot, ADR-0012) —
      15              : //! persistent modes serve snapshots across restarts; 5.5.15 still allows
      16              : //! dropping them under resource pressure (evict_over_cap).
      17              : //! Fills follow the 5.7.2.4 distributed path and page past max_limit, up to
      18              : //! the 5.5.6 result ceiling (fill_cap) and only for as long as the snapshot
      19              : //! exists — a fill whose snapshot was deleted or evicted frees its copy
      20              : //! (fill_cancelled), which nothing else could reach.
      21              : //! Resource pressure evicts lowest-priority snapshots (evict_over_cap).
      22              : 
      23              : use crate::negotiate::{
      24              :     check_params, created, no_content, parse_accept, parse_body, respond, single_header,
      25              :     tenant_from, ApiError, ApiResult, BodyKind, CleanParams,
      26              : };
      27              : use crate::state::{now_iso, AppState};
      28              : use antares_model::{NgsiError, TenantId, API_ROOT};
      29              : use antares_store::CurrentStateDriverExt;
      30              : use antares_store::Kind;
      31              : use axum::body::Bytes;
      32              : use axum::extract::{Path, State};
      33              : use axum::http::{HeaderMap, StatusCode};
      34              : use axum::response::{IntoResponse, Response};
      35              : use serde_json::{json, Map, Value};
      36              : use std::collections::HashMap;
      37              : 
      38              : const DEFAULT_LIFETIME_SECS: i64 = 86_400; // 1 day
      39              : const MAX_LIFETIME_SECS: i64 = 604_800; // 7 days — the "configured limit"
      40              : 
      41          122 : fn bad(m: String) -> NgsiError {
      42          122 :     NgsiError::BadRequestData(m)
      43          122 : }
      44              : 
      45              : /// 5.5.6: an unexpected failure while filling a snapshot surfaces as
      46              : /// InternalError. Its detail reaches the client twice — in
      47              : /// snapshotQueriesDetails.problemDetails (5.2.41) and in the 5.3.4
      48              : /// SnapshotNotification body — so the internal error text stays in the
      49              : /// server log and the client-visible detail is generic.
      50            4 : fn opaque(what: &str, e: &dyn std::fmt::Debug) -> NgsiError {
      51            4 :     tracing::error!("snapshot {what} failed: {e:?}");
      52            4 :     NgsiError::InternalError(format!("{what} failed"))
      53            4 : }
      54              : 
      55              : /// 5.2.41: expiresAt from the suggested snapshotLifetime, bounded by the
      56              : /// system limit (5.16.1.4 "applying the configured limit").
      57          196 : fn expires_at(meta: &Map<String, Value>) -> Result<String, NgsiError> {
      58          196 :     let secs = match meta.get("snapshotLifetime").and_then(Value::as_str) {
      59           14 :         Some(d) => crate::entity_map::iso8601_secs(d)
      60           14 :             .ok_or_else(|| {
      61            0 :                 bad(format!(
      62              :                     "snapshotLifetime is not an ISO 8601 duration: {d:?}"
      63              :                 ))
      64            0 :             })?
      65              :             // A zero or negative suggestion would answer 201 with a Location
      66              :             // that is already expired, so the broker floor applies as well as
      67              :             // the ceiling — the same reasoning the EntityMap lifetime carries.
      68           14 :             .clamp(1, MAX_LIFETIME_SECS),
      69          182 :         None => DEFAULT_LIFETIME_SECS,
      70              :     };
      71              :     // `secs` is clamped to MAX_LIFETIME_SECS above, far inside chrono's range.
      72          196 :     let now = chrono::Utc::now();
      73          196 :     Ok(now
      74          196 :         .checked_add_signed(chrono::Duration::seconds(secs))
      75          196 :         .unwrap_or(now)
      76          196 :         .to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
      77          196 : }
      78              : 
      79         2192 : fn expired(meta: &Value) -> bool {
      80         2192 :     meta.get("expiresAt")
      81         2192 :         .and_then(Value::as_str)
      82         2192 :         .and_then(|e| chrono::DateTime::parse_from_rfc3339(e).ok())
      83         2192 :         .is_some_and(|e| e < chrono::Utc::now())
      84         2192 : }
      85              : 
      86              : /// The internal tenant holding the synth-tenant -> (owner, snapshot id)
      87              : /// index docs (durable reverse lookup for 6.3.22 notification stamping).
      88          230 : fn snap_index_tenant() -> Option<TenantId> {
      89          230 :     TenantId::new_internal("snap-index").ok()
      90          230 : }
      91              : 
      92              : /// 5.2.41: a Snapshot's type is fixed to "Snapshot". The reverse-index
      93              : /// marker docs share Kind::Snapshot storage but are not Snapshots, so every
      94              : /// place that reads a document as one filters on this — a listing that does
      95              : /// not would count and select the markers as snapshots.
      96         2436 : fn is_snapshot(meta: &Value) -> bool {
      97         2436 :     meta.get("type").and_then(Value::as_str) == Some("Snapshot")
      98         2436 : }
      99              : 
     100              : /// Registry access with lazy expiry: an expired snapshot is gone to every
     101              : /// caller. Reading is a read — the meta row, its reverse-index entry and
     102              : /// the synthetic tenant's data are freed by `sweep_expired_snapshots` on
     103              : /// the sweep tick, not by the request that found the expiry. Snapshot docs
     104              : /// live in the store (Kind::Snapshot) so restarts keep them on persistent
     105              : /// store modes.
     106         1262 : pub(crate) async fn snap_get(st: &AppState, tenant: &TenantId, id: &str) -> Option<Value> {
     107         1262 :     st.store
     108         1262 :         .get(tenant, Kind::Snapshot, id)
     109         1262 :         .await
     110         1262 :         .ok()
     111         1262 :         .flatten()
     112         1262 :         .filter(|meta| is_snapshot(meta) && !expired(meta))
     113         1262 : }
     114              : 
     115              : /// 5.16.1.4: "If the NGSI-LD endpoint already knows about this Snapshot …
     116              : /// an error of type AlreadyExists shall be raised." The store's create is
     117              : /// the atomic check-and-insert, so two concurrent creates of the same id
     118              : /// cannot both succeed and overwrite each other's synthetic tenant.
     119          184 : async fn snap_insert(
     120          184 :     st: &AppState,
     121          184 :     tenant: &TenantId,
     122          184 :     id: &str,
     123          184 :     meta: &Value,
     124          184 : ) -> Result<(), NgsiError> {
     125          184 :     if !st
     126          184 :         .store
     127          184 :         .create(tenant, Kind::Snapshot, id, meta.clone())
     128          184 :         .await?
     129              :     {
     130            4 :         return Err(NgsiError::AlreadyExists(format!(
     131            4 :             "snapshot {id} already exists"
     132            4 :         )));
     133          180 :     }
     134              :     // Durable reverse index for snapshot_of_synth. The marker carries the
     135              :     // synthetic tenant as its `id` as well as its key: it is the only record
     136              :     // that names that tenant once the Snapshot document is gone, and a
     137              :     // document a listing cannot identify is one nothing can walk.
     138          180 :     if let (Some(synth), Some(idx)) = (
     139          180 :         meta.get("__tenant").and_then(Value::as_str),
     140          180 :         snap_index_tenant(),
     141              :     ) {
     142          180 :         let _ = st
     143          180 :             .store
     144          180 :             .create(
     145          180 :                 &idx,
     146          180 :                 Kind::Snapshot,
     147          180 :                 synth,
     148          180 :                 json!({"id": synth, "tenant": tenant.as_str(), "snapshot": id}),
     149          180 :             )
     150          180 :             .await;
     151            0 :     }
     152          180 :     Ok(())
     153          184 : }
     154              : 
     155              : /// Members `finish` owns (5.2.41 Table 5.2.41-2, output only).
     156              : const FINISH_MEMBERS: &[&str] = &[
     157              :     "snapshotStatus",
     158              :     "modifiedAt",
     159              :     "snapshotQueriesDetails",
     160              :     "snapshotTemporalQueriesDetails",
     161              : ];
     162              : 
     163              : /// Members an Update Snapshot (5.16.4.4) writes.
     164              : const UPDATE_MEMBERS: &[&str] = &[
     165              :     "snapshotLifetime",
     166              :     "snapshotPriority",
     167              :     "endpoint",
     168              :     "receiverInfo",
     169              :     "expiresAt",
     170              :     "modifiedAt",
     171              : ];
     172              : 
     173              : /// Write `members` of `meta` back onto a snapshot that already exists (a
     174              : /// member `meta` lacks is removed); a snapshot deleted meanwhile is not
     175              : /// resurrected. Only the named members are written, so a fill finishing
     176              : /// beside an Update Snapshot keeps the other's members: each writer read
     177              : /// the document before the other wrote it. Returns the stored document.
     178          193 : async fn snap_put(
     179          193 :     st: &AppState,
     180          193 :     tenant: &TenantId,
     181          193 :     meta: &Value,
     182          193 :     members: &[&str],
     183          193 : ) -> Option<Value> {
     184          193 :     let id = meta.get("id").and_then(Value::as_str)?;
     185          193 :     st.store
     186          193 :         .mutate(tenant, Kind::Snapshot, id, |d| {
     187          193 :             if let Some(o) = d.as_object_mut() {
     188          808 :                 for k in members {
     189          808 :                     match meta.get(*k) {
     190          561 :                         Some(v) => o.insert((*k).to_owned(), v.clone()),
     191          247 :                         None => o.remove(*k),
     192              :                     };
     193              :                 }
     194            0 :             }
     195          193 :             Ok::<_, std::convert::Infallible>(d.clone())
     196          193 :         })
     197          193 :         .await
     198          193 :         .ok()
     199          193 :         .flatten()
     200          193 :         .and_then(Result::ok)
     201          193 : }
     202              : 
     203              : /// Remove a snapshot everywhere: doc, synth-tenant index, data purge.
     204           46 : pub(crate) async fn snap_remove(st: &AppState, tenant: &TenantId, id: &str, meta: &Value) {
     205           46 :     let _ = st.store.delete(tenant, Kind::Snapshot, id).await;
     206           46 :     purge_data_bg(st, meta);
     207           46 : }
     208              : 
     209              : /// 4.22 for snapshots: `snap_get` refuses an expired one, and this is what
     210              : /// removes it — meta, reverse-index entry and the synthetic tenant's data,
     211              : /// through the same `snap_remove` a read used to call, so an expiry
     212              : /// collected on the tick frees exactly what an expiry collected on a read
     213              : /// freed.
     214       145070 : pub(crate) async fn sweep_expired_snapshots(st: &AppState, tenant: &TenantId) -> usize {
     215       145070 :     let mut dead: Vec<(String, Value)> = Vec::new();
     216       145070 :     if crate::csource::walk_docs(st, tenant, Kind::Snapshot, |doc| {
     217         1012 :         if is_snapshot(&doc) && expired(&doc) {
     218            8 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
     219            8 :                 dead.push((id.to_owned(), doc));
     220            8 :             }
     221         1004 :         }
     222         1012 :         Ok(())
     223         1012 :     })
     224       145070 :     .await
     225       145070 :     .is_err()
     226              :     {
     227              :         // nothing is freed this round, and that is not silent: the tenant
     228              :         // only, no error text (a value that would not open is a credential)
     229           12 :         tracing::error!(
     230            0 :             tenant = tenant.as_str(),
     231              :             "the expired-snapshot sweep could not list this tenant's snapshots"
     232              :         );
     233           12 :         return 0;
     234       145058 :     }
     235       145058 :     let n = dead.len();
     236       145058 :     for (id, meta) in dead {
     237            8 :         snap_remove(st, tenant, &id, &meta).await;
     238              :     }
     239       145058 :     n
     240       145070 : }
     241              : 
     242          365 : fn synth_tenant(meta: &Value) -> Option<TenantId> {
     243          365 :     meta.get("__tenant")
     244          365 :         .and_then(Value::as_str)
     245          365 :         .and_then(|t| TenantId::new_internal(t).ok())
     246          365 : }
     247              : 
     248              : /// Free the snapshot's isolated copy. 5.5.15 permits every Core and
     249              : /// Temporal operation on a snapshot, so a snapshot-scoped request can put
     250              : /// documents of ANY kind under the synthetic tenant — once the snapshot is
     251              : /// gone none of them is reachable (no client may name the internal tenant),
     252              : /// so every kind is dropped, not only the filled data.
     253              : ///
     254              : /// The tenant purge, not a per-document sweep: it covers every kind rather
     255              : /// than a list that has to be kept in step with `Kind`, and it removes the
     256              : /// tenant itself. A sweep leaves the empty tenant behind, and `/q/tenants`
     257              : /// reports what the store holds a tenant entry for — so every snapshot ever
     258              : /// deleted would leave a `snap-<uuid>` name in the inventory for the life of
     259              : /// the deployment.
     260           52 : async fn purge_synth(st: &AppState, synth: &TenantId) {
     261              :     // history lives behind its own driver seam
     262           52 :     let _ = st.temporal.purge_tenant(synth).await;
     263           52 :     let _ = st.store.purge_tenant(synth).await;
     264           52 : }
     265              : 
     266              : /// The teardown that outlives the request: the copy is dropped, and only
     267              : /// then the reverse-index entry that named the synthetic tenant. That order
     268              : /// is the recovery path — the Snapshot document is already gone, so the index
     269              : /// entry is the last thing that says which tenant holds the copy, and a
     270              : /// broker that stops mid-teardown leaves it pointing at data still there
     271              : /// rather than data nothing names.
     272           46 : fn purge_data_bg(st: &AppState, meta: &Value) {
     273           46 :     let Some(synth) = synth_tenant(meta) else {
     274            0 :         return;
     275              :     };
     276           46 :     let st = st.clone();
     277           46 :     crate::spawn(async move {
     278           46 :         purge_synth(&st, &synth).await;
     279           46 :         if let Some(idx) = snap_index_tenant() {
     280           46 :             let _ = st.store.delete(&idx, Kind::Snapshot, synth.as_str()).await;
     281            0 :         }
     282           46 :     });
     283           46 : }
     284              : 
     285              : /// The snapshot document as presented to clients (internal members hidden).
     286              : /// `receiverInfo` and userinfo in `endpoint` are the credentials the 5.16.6
     287              : /// notification is sent with, so a read answers them masked, exactly as a
     288              : /// Subscription's (`subscriptions::mask_receiver_info`).
     289          276 : fn present(meta: &Value) -> Value {
     290          276 :     let mut out = meta.clone();
     291          276 :     crate::policy::strip_internal(&mut out);
     292          276 :     if let Some(o) = out.as_object_mut() {
     293          276 :         let mut n = as_notification(o);
     294          276 :         crate::subscriptions::mask_receiver_info(&mut n);
     295          276 :         from_notification(o, n);
     296          276 :     }
     297          276 :     out
     298          276 : }
     299              : 
     300              : /// A Snapshot's `endpoint` and `receiverInfo` (Table 5.2.41-1) in the
     301              : /// `notification.endpoint` shape a Subscription keeps them in (5.2.15), so
     302              : /// both go through the one masking rule.
     303          296 : fn as_notification(o: &Map<String, Value>) -> Map<String, Value> {
     304          296 :     let mut ep = Map::new();
     305          296 :     if let Some(u) = o.get("endpoint") {
     306           46 :         ep.insert("uri".into(), u.clone());
     307          250 :     }
     308          296 :     if let Some(r) = o.get("receiverInfo") {
     309           38 :         ep.insert("receiverInfo".into(), r.clone());
     310          258 :     }
     311          296 :     Map::from_iter([("endpoint".to_owned(), Value::Object(ep))])
     312          296 : }
     313              : 
     314              : /// [`as_notification`] back: the members it carried, as they now stand.
     315          284 : fn from_notification(o: &mut Map<String, Value>, n: Map<String, Value>) {
     316          284 :     let Some(Value::Object(ep)) = n.get("endpoint") else {
     317            0 :         return;
     318              :     };
     319          568 :     for (from, to) in [("uri", "endpoint"), ("receiverInfo", "receiverInfo")] {
     320          568 :         if let (Some(v), true) = (ep.get(from), o.contains_key(to)) {
     321           60 :             o.insert(to.into(), v.clone());
     322          508 :         }
     323              :     }
     324          284 : }
     325              : 
     326              : /// 5.16.4 update of the notification members. A client that read the
     327              : /// Snapshot and writes it back sends the masked values it was answered:
     328              : /// those keep the stored credential, and only beside the stored endpoint
     329              : /// (`subscriptions::keep_masked_receiver_info`). The credential stays with
     330              : /// its endpoint the other way too: an update that moves the endpoint and
     331              : /// sends no `receiverInfo` drops the stored one, or the next notification
     332              : /// would carry it to whatever host the update named.
     333           20 : fn keep_receiver_info(
     334           20 :     frag: &mut Map<String, Value>,
     335           20 :     stored: &Map<String, Value>,
     336           20 : ) -> Result<(), NgsiError> {
     337           20 :     if !frag.contains_key("endpoint") && !frag.contains_key("receiverInfo") {
     338           10 :         return Ok(());
     339           10 :     }
     340           10 :     let mut f = Map::from_iter([(
     341           10 :         "notification".to_owned(),
     342           10 :         Value::Object(as_notification(frag)),
     343           10 :     )]);
     344           10 :     let held = Map::from_iter([(
     345           10 :         "notification".to_owned(),
     346           10 :         Value::Object(as_notification(stored)),
     347           10 :     )]);
     348           10 :     crate::subscriptions::keep_masked_receiver_info(&mut f, &held)?;
     349            8 :     if let Some(Value::Object(n)) = f.remove("notification") {
     350            8 :         from_notification(frag, n);
     351            8 :     }
     352            8 :     let moved = frag
     353            8 :         .get("endpoint")
     354            8 :         .is_some_and(|e| Some(e) != stored.get("endpoint"));
     355            8 :     if moved && !frag.contains_key("receiverInfo") && stored.contains_key("receiverInfo") {
     356            2 :         frag.insert("receiverInfo".into(), Value::Null);
     357            6 :     }
     358            8 :     Ok(())
     359           20 : }
     360              : 
     361              : enum Mode {
     362              :     Create,
     363              :     Clone,
     364              :     Update,
     365              : }
     366              : 
     367              : /// 5.2.41 Table 5.2.41-1/-2 validation. Output-only members are IGNORED
     368              : /// (stripped); read-only members in the wrong mode are BadRequestData.
     369          282 : fn validate(body: &Value, mode: Mode) -> Result<Map<String, Value>, NgsiError> {
     370          282 :     let mut o = body
     371          282 :         .as_object()
     372          282 :         .cloned()
     373          282 :         .ok_or_else(|| bad("snapshot must be a JSON object".into()))?;
     374          282 :     o.remove("@context");
     375              :     // the broker's own members (the synthetic tenant, the creating subject)
     376              :     // are not the client's to send — see policy::strip_internal
     377          614 :     o.retain(|k, _| !k.starts_with("__"));
     378              :     // output-only members (Table 5.2.41-2) "shall be ignored"
     379         1974 :     for k in [
     380          282 :         "snapshotStatus",
     381          282 :         "snapshotQueriesDetails",
     382          282 :         "snapshotTemporalQueriesDetails",
     383          282 :         "createdAt",
     384          282 :         "modifiedAt",
     385          282 :         "expiresAt",
     386          282 :         "lastUsedAt",
     387         1974 :     ] {
     388         1974 :         o.remove(k);
     389         1974 :     }
     390          282 :     match mode {
     391              :         Mode::Create => {
     392          204 :             if o.get("type").and_then(Value::as_str) != Some("Snapshot") {
     393            2 :                 return Err(bad("type must be \"Snapshot\" (5.2.41)".into()));
     394          202 :             }
     395          202 :             if !o.contains_key("snapshotQueries") && !o.contains_key("snapshotTemporalQueries") {
     396           12 :                 return Err(bad(
     397           12 :                     "at least one of snapshotQueries or snapshotTemporalQueries \
     398           12 :                      shall be present (5.2.41)"
     399           12 :                         .into(),
     400           12 :                 ));
     401          190 :             }
     402              :         }
     403              :         Mode::Clone | Mode::Update => {
     404              :             // "both shall be omitted when updating the Snapshot status or
     405              :             // cloning the Snapshot" — read-only after creation
     406           78 :             if o.contains_key("snapshotQueries") || o.contains_key("snapshotTemporalQueries") {
     407            8 :                 return Err(bad(
     408            8 :                     "snapshotQueries/snapshotTemporalQueries are read-only after \
     409            8 :                      creation (5.2.41)"
     410            8 :                         .into(),
     411            8 :                 ));
     412           70 :             }
     413              :             // Table 5.2.41-1: the id "cannot be later modified in update
     414              :             // operations" — of whatever JSON type it is sent as
     415           70 :             if matches!(mode, Mode::Update) && o.contains_key("id") {
     416            8 :                 return Err(bad("the snapshot id cannot be modified (5.2.41)".into()));
     417           62 :             }
     418              :             // 5.16.4.4 modifies the Snapshot "as mandated by clause 5.5.8":
     419              :             // a member whose value is an NGSI-LD Null, in either form, is
     420              :             // removed. It reaches the merge as JSON null, which the type
     421              :             // checks below pass by.
     422           62 :             if matches!(mode, Mode::Update) {
     423          228 :                 for k in UPDATE_MEMBERS {
     424          228 :                     if o.get(*k)
     425          228 :                         .is_some_and(|v| v.is_null() || v.as_str() == Some("urn:ngsi-ld:null"))
     426           16 :                     {
     427           16 :                         o.insert((*k).to_owned(), Value::Null);
     428          212 :                     }
     429              :                 }
     430           24 :             }
     431              :         }
     432              :     }
     433              :     // an update's removal (above); anywhere else a null is a wrong type
     434          252 :     let removed = |v: &Value| matches!(mode, Mode::Update) && v.is_null();
     435          498 :     for (key, temporal) in [
     436          252 :         ("snapshotQueries", false),
     437          252 :         ("snapshotTemporalQueries", true),
     438          252 :     ] {
     439          498 :         if let Some(qs) = o.get(key) {
     440          194 :             let arr = qs
     441          194 :                 .as_array()
     442          194 :                 .filter(|a| !a.is_empty())
     443          194 :                 .ok_or_else(|| bad(format!("{key} must be a non-empty array of Query (5.2.41)")))?;
     444          194 :             for q in arr {
     445          194 :                 let qo = q
     446          194 :                     .as_object()
     447          194 :                     .ok_or_else(|| bad(format!("{key} entries must be Query objects (5.2.23)")))?;
     448          194 :                 if qo.get("type").and_then(Value::as_str) != Some("Query") {
     449            0 :                     return Err(bad(format!("{key} entries must have type Query (5.2.23)")));
     450          194 :                 }
     451          194 :                 if temporal != qo.contains_key("temporalQ") {
     452            6 :                     return Err(bad(format!(
     453              :                         "{key} entries must {} a temporalQ element (5.2.41)",
     454            6 :                         if temporal { "carry" } else { "not carry" }
     455              :                     )));
     456          188 :                 }
     457              :             }
     458          304 :         }
     459              :     }
     460          246 :     if let Some(p) = o.get("snapshotPriority").filter(|v| !removed(v)) {
     461           56 :         let ok = p.as_i64().is_some_and(|n| (1..=10).contains(&n));
     462           56 :         if !ok {
     463            2 :             return Err(bad(
     464            2 :                 "snapshotPriority must be an integer between 1 and 10 (5.2.41)".into(),
     465            2 :             ));
     466           54 :         }
     467          190 :     }
     468          244 :     if let Some(l) = o.get("snapshotLifetime").filter(|v| !removed(v)) {
     469           18 :         let ok = l
     470           18 :             .as_str()
     471           18 :             .and_then(crate::entity_map::iso8601_secs)
     472           18 :             .is_some();
     473           18 :         if !ok {
     474            4 :             return Err(bad(
     475            4 :                 "snapshotLifetime must be an ISO 8601 duration (5.2.41)".into(),
     476            4 :             ));
     477           14 :         }
     478          226 :     }
     479              :     // Table 5.2.41-1: endpoint is a "Dereferenceable URI", so a string that is
     480              :     // no URI at all names nothing the 5.16.6 notification could be sent to.
     481          240 :     if let Some(e) = o.get("endpoint").filter(|v| !removed(v)) {
     482           42 :         let uri = e
     483           42 :             .as_str()
     484           42 :             .ok_or_else(|| bad("endpoint must be a URI string (5.2.41)".into()))?;
     485           38 :         antares_model::EntityId::new(uri).map_err(|_| {
     486            2 :             bad(format!(
     487              :                 "endpoint is not a valid URI: {:?} (5.2.41)",
     488            2 :                 antares_notifier::redact_userinfo(uri)
     489              :             ))
     490            2 :         })?;
     491              :         // 5.16.6 sends the SnapshotNotification by HTTP POST: an endpoint
     492              :         // that binding cannot send to (one written `http:user:pass@host`,
     493              :         // without the `//`, included) names nothing the notification could
     494              :         // reach, and its userinfo would escape the masking and the sealing,
     495              :         // which read the credential after a `//`
     496           36 :         antares_notifier::http::check_http_endpoint(uri)?;
     497          198 :     }
     498              :     // Table 5.2.41-1: receiverInfo is a KeyValuePair[] (5.2.22 — both members
     499              :     // Strings), and 5.16.6 renders each pair as one header on the
     500              :     // SnapshotNotification POST. 6.3.8 binds those to RFC 7230 for the same
     501              :     // reason it binds a Subscription's: a pair that cannot be a header leaves
     502              :     // a Snapshot whose notification can only ever fail, silently.
     503          226 :     if let Some(ri) = o.get("receiverInfo").filter(|v| !removed(v)) {
     504           24 :         let pairs = ri
     505           24 :             .as_array()
     506           24 :             .ok_or_else(|| bad("receiverInfo must be a KeyValuePair array (5.2.41)".into()))?;
     507           22 :         for kv in pairs {
     508           22 :             let (k, v) = (kv["key"].as_str(), kv["value"].as_str());
     509           22 :             if !k.is_some_and(crate::negotiate::is_field_name)
     510           20 :                 || !v.is_some_and(crate::negotiate::is_field_value)
     511              :             {
     512            6 :                 return Err(bad(format!(
     513            6 :                     "receiverInfo entry {kv} is not a valid HTTP header (RFC 7230, 6.3.8)"
     514            6 :                 )));
     515           16 :             }
     516              :         }
     517          202 :     }
     518          218 :     Ok(o)
     519          282 : }
     520              : 
     521              : /// Fresh metadata for a new snapshot (create or clone) — 5.16.1.4/5.16.2.4:
     522              : /// timestamps now, status "preparing", priority default 5, bounded expiresAt.
     523          184 : fn new_meta(
     524          184 :     mut o: Map<String, Value>,
     525          184 :     tenant: &TenantId,
     526          184 :     headers: &HeaderMap,
     527          184 : ) -> Result<(String, Value), NgsiError> {
     528          184 :     let id = match o.get("id").and_then(Value::as_str) {
     529           16 :         Some(id) => {
     530           16 :             antares_model::EntityId::new(id)
     531           16 :                 .map_err(|_| bad(format!("snapshot id is not a valid URI: {id:?}")))?;
     532           16 :             id.to_owned()
     533              :         }
     534              :         None => {
     535          168 :             let id = format!("urn:ngsi-ld:Snapshot:{}", uuid::Uuid::new_v4());
     536          168 :             o.insert("id".into(), Value::String(id.clone()));
     537          168 :             id
     538              :         }
     539              :     };
     540          184 :     let ts = now_iso();
     541          184 :     o.insert("type".into(), Value::String("Snapshot".into()));
     542          184 :     o.insert("createdAt".into(), Value::String(ts.clone()));
     543          184 :     o.insert("modifiedAt".into(), Value::String(ts));
     544          184 :     o.insert("snapshotStatus".into(), Value::String("preparing".into()));
     545              :     // 5.2.41 Table 5.2.41-2: lastUsedAt "is initialized at creation time"
     546          184 :     o.insert("lastUsedAt".into(), Value::String(now_iso()));
     547          184 :     o.insert("expiresAt".into(), Value::String(expires_at(&o)?));
     548          184 :     o.entry("snapshotPriority".to_owned())
     549          184 :         .or_insert(Value::Number(5.into()));
     550          184 :     o.insert(
     551          184 :         "__tenant".into(),
     552          184 :         Value::String(format!("snap-{}", uuid::Uuid::new_v4().simple())),
     553              :     );
     554              :     // Whose snapshot this is. 5.16.1's fill runs after the request has been
     555              :     // answered, so the subject it copies under has to be recorded here or it
     556              :     // is gone; and 5.16.1/5.16.2/5.16.7 act on everything the tenant holds,
     557              :     // which is why a narrowing answer to any of them is a refusal
     558              :     // (policy::WHOLE_TENANT) rather than a smaller snapshot.
     559            6 :     if let Some(subject) =
     560          184 :         crate::policy::subject_member(&crate::policy::subject_of(tenant, headers))
     561            6 :     {
     562            6 :         o.insert(crate::policy::SUBJECT_MEMBER.into(), subject);
     563          178 :     }
     564          184 :     Ok((id, Value::Object(o)))
     565          184 : }
     566              : 
     567              : /// 5.2.41: lastUsedAt tracks "the point in time when the snapshot was most
     568              : /// recently used" — refreshed on every snapshot-scoped operation.
     569           78 : async fn snap_touch(st: &AppState, tenant: &TenantId, id: &str) {
     570           78 :     let _ = st
     571           78 :         .store
     572           78 :         .mutate(tenant, Kind::Snapshot, id, |meta| {
     573           78 :             if let Some(o) = meta.as_object_mut() {
     574           78 :                 o.insert("lastUsedAt".into(), Value::String(now_iso()));
     575           78 :             }
     576           78 :             Ok::<_, std::convert::Infallible>(())
     577           78 :         })
     578           78 :         .await;
     579           78 : }
     580              : 
     581              : /// Reverse lookup: which (owner tenant, snapshot id) does a synthetic
     582              : /// "snap-…" tenant belong to? Used to stamp NGSILD-Snapshot on
     583              : /// notifications from snapshot-scoped subscriptions (6.3.22) without
     584              : /// leaking the internal tenant.
     585         8522 : pub(crate) async fn snapshot_of_synth(st: &AppState, synth: &str) -> Option<(TenantId, String)> {
     586              :     // only synthetic tenants are indexed; every other tenant skips the lookup
     587         8522 :     if !synth.starts_with("snap-") {
     588         8518 :         return None;
     589            4 :     }
     590            4 :     let idx = snap_index_tenant()?;
     591            4 :     let doc = st
     592            4 :         .store
     593            4 :         .get(&idx, Kind::Snapshot, synth)
     594            4 :         .await
     595            4 :         .ok()
     596            4 :         .flatten()?;
     597            4 :     let owner = TenantId::new(doc.get("tenant")?.as_str()?).ok()?;
     598            4 :     Some((owner, doc.get("snapshot")?.as_str()?.to_owned()))
     599         8522 : }
     600              : 
     601              : /// The tenant an operation is judged under. 6.3.22 scopes a request to a
     602              : /// Snapshot by swapping the tenant for the snapshot's internal one, so the
     603              : /// handlers below it serve the frozen copy; the subject asking is still the
     604              : /// owner tenant's. An engine keyed by tenant — which is the shape every
     605              : /// rule set has — would otherwise be asked about an id no deployment ever
     606              : /// wrote a rule for, and every snapshot-scoped read would step out from
     607              : /// under its rules. Only a synthetic tenant costs the lookup.
     608         3448 : pub(crate) async fn asking_tenant(st: &AppState, tenant: &TenantId) -> TenantId {
     609         3448 :     match snapshot_of_synth(st, tenant.as_str()).await {
     610            2 :         Some((owner, _)) => owner,
     611         3446 :         None => tenant.clone(),
     612              :     }
     613         3448 : }
     614              : 
     615              : /// 5.5.15: "If an implementation determines that it is low on resources,
     616              : /// it may delete one or more snapshots", considering snapshotPriority
     617              : /// (lowest first; earliest expiresAt breaks ties). The resource signal is
     618              : /// the per-tenant registry cap (AppState.snapshot_cap); the just-created
     619              : /// snapshot is never the victim. Evicted snapshots with an endpoint are
     620              : /// notified with expiresAt set before notifiedAt — the 5.3.4 deletion
     621              : /// encoding.
     622              : ///
     623              : /// A registry that cannot be listed is an error, not an empty one: read as
     624              : /// empty, the cap was never enforced while the listing failed.
     625          180 : async fn evict_over_cap(st: &AppState, tenant: &TenantId, keep: &str) -> Result<(), NgsiError> {
     626            4 :     let victims: Vec<Value> = {
     627          180 :         let mut metas: Vec<Value> = st
     628          180 :             .store
     629          180 :             .list(tenant, Kind::Snapshot)
     630          180 :             .await?
     631          178 :             .into_iter()
     632          178 :             .filter(is_snapshot)
     633          178 :             .collect();
     634          178 :         if metas.len() <= st.snapshot_cap {
     635          174 :             return Ok(());
     636            4 :         }
     637            4 :         let over = metas.len().saturating_sub(st.snapshot_cap);
     638           22 :         metas.sort_by_key(|v| {
     639           22 :             (
     640           22 :                 v.get("snapshotPriority")
     641           22 :                     .and_then(Value::as_i64)
     642           22 :                     .unwrap_or(5),
     643           22 :                 v.get("expiresAt")
     644           22 :                     .and_then(Value::as_str)
     645           22 :                     .unwrap_or("")
     646           22 :                     .to_owned(),
     647           22 :             )
     648           22 :         });
     649            4 :         metas
     650            4 :             .into_iter()
     651            4 :             .filter(|v| v.get("id").and_then(Value::as_str) != Some(keep))
     652            4 :             .take(over)
     653            4 :             .collect()
     654              :     };
     655            4 :     for mut meta in victims {
     656            4 :         if let Some(id) = meta.get("id").and_then(Value::as_str).map(str::to_owned) {
     657            4 :             snap_remove(st, tenant, &id, &meta).await;
     658            0 :         }
     659            4 :         if let Some(o) = meta.as_object_mut() {
     660            4 :             // deletion signal: expiresAt strictly before the notification
     661            4 :             o.insert("expiresAt".into(), Value::String(now_iso()));
     662            4 :         }
     663            4 :         let st2 = st.clone();
     664            4 :         crate::spawn(async move {
     665            4 :             send_notification(&st2, &meta).await;
     666            4 :         });
     667              :     }
     668            4 :     Ok(())
     669          180 : }
     670              : 
     671              : // ---------- 5.16.1 Create Snapshot (POST /snapshots, 6.36.3.1) ----------
     672              : 
     673          214 : pub async fn create_snapshot(
     674          214 :     State(st): State<AppState>,
     675          214 :     CleanParams(params): CleanParams,
     676          214 :     headers: HeaderMap,
     677          214 :     body: Bytes,
     678          214 : ) -> Response {
     679          214 :     let go = async {
     680          214 :         let tenant = tenant_from(&headers)?;
     681          214 :         check_params(&params, &["local"])?;
     682          214 :         gate!(st, &tenant, &headers, "5.16.1").await?;
     683              :         // 6.3.5: the @context rules apply to the snapshot body like to any
     684              :         // other POST payload (media type, Link header, body @context)
     685          212 :         let parsed = parse_body(&st, &headers, &body, BodyKind::Standard).await?;
     686          200 :         let mut o = validate(&parsed.value, Mode::Create)?;
     687              :         // The queries are written in the terms of this request's @context
     688              :         // (6.3.5) and run after it has been answered, so the context is
     689              :         // recorded with them, as a Subscription records its own.
     690          160 :         o.insert("__context".into(), parsed.ctx.source.clone());
     691          160 :         let (id, meta) = new_meta(o, &tenant, &headers)?;
     692          160 :         snap_insert(&st, &tenant, &id, &meta).await?;
     693              :         // the cap cannot be checked: the new snapshot does not stay
     694          156 :         if let Err(e) = evict_over_cap(&st, &tenant, &id).await {
     695            2 :             snap_remove(&st, &tenant, &id, &meta).await;
     696            2 :             return Err(e.into());
     697          154 :         }
     698          154 :         let (st2, t2, id2) = (st.clone(), tenant.clone(), id.clone());
     699          154 :         crate::spawn(async move {
     700          149 :             fill_snapshot(&st2, &t2, &id2).await;
     701          146 :         });
     702          154 :         Ok::<_, ApiError>(created(format!("/ngsi-ld/v1/snapshots/{id}"), &tenant))
     703          214 :     };
     704          214 :     go.await.unwrap_or_else(|e| e.into_response())
     705          214 : }
     706              : 
     707              : /// 5.16.1.4 background fill: execute every (temporal) query, store the
     708              : /// results under the synthetic tenant, derive the status, notify.
     709          149 : async fn fill_snapshot(st: &AppState, tenant: &TenantId, id: &str) {
     710          149 :     let Some(meta) = snap_get(st, tenant, id).await else {
     711            0 :         return;
     712              :     };
     713          149 :     let Some(synth) = synth_tenant(&meta) else {
     714            0 :         return;
     715              :     };
     716          149 :     let ctx = snapshot_context(st, tenant, &meta).await;
     717          149 :     let (mut n_fail, mut n_res, mut n_empty, mut copied) = (0usize, 0usize, 0usize, 0usize);
     718          151 :     let mut detail = |r: Result<usize, NgsiError>| -> Value {
     719          151 :         match r {
     720              :             Ok(0) => {
     721           54 :                 n_empty = n_empty.saturating_add(1);
     722           54 :                 json!({"resultStatus": "empty"})
     723              :             }
     724              :             Ok(_) => {
     725           87 :                 n_res = n_res.saturating_add(1);
     726           87 :                 json!({"resultStatus": "success"})
     727              :             }
     728           10 :             Err(e) => {
     729           10 :                 n_fail = n_fail.saturating_add(1);
     730           10 :                 json!({"resultStatus": "failure",
     731           10 :                        "problemDetails": crate::negotiate::problem_value(&e)})
     732              :             }
     733              :         }
     734          151 :     };
     735          149 :     let cap = fill_cap(st);
     736          149 :     let mut q_details = Vec::new();
     737          149 :     for q in meta
     738          149 :         .get("snapshotQueries")
     739          149 :         .and_then(Value::as_array)
     740          149 :         .into_iter()
     741          149 :         .flatten()
     742              :     {
     743          133 :         if fill_cancelled(st, tenant, id, &synth).await {
     744            2 :             return;
     745          131 :         }
     746          131 :         let r = run_query(st, tenant, &synth, q, &ctx, cap.saturating_sub(copied)).await;
     747          131 :         if let Ok(n) = &r {
     748          125 :             copied = copied.saturating_add(*n);
     749          125 :         }
     750          131 :         q_details.push(detail(r));
     751              :     }
     752          147 :     let mut tq_details = Vec::new();
     753          147 :     for q in meta
     754          147 :         .get("snapshotTemporalQueries")
     755          147 :         .and_then(Value::as_array)
     756          147 :         .into_iter()
     757          147 :         .flatten()
     758              :     {
     759           20 :         if fill_cancelled(st, tenant, id, &synth).await {
     760            0 :             return;
     761           20 :         }
     762           20 :         let r =
     763           20 :             run_temporal_query(st, tenant, &synth, id, q, &ctx, cap.saturating_sub(copied)).await;
     764           20 :         if let Ok(n) = &r {
     765           16 :             copied = copied.saturating_add(*n);
     766           16 :         }
     767           20 :         tq_details.push(detail(r));
     768              :     }
     769          147 :     if fill_cancelled(st, tenant, id, &synth).await {
     770            0 :         return;
     771          147 :     }
     772          147 :     let status = if n_fail == 0 && n_empty == 0 && n_res > 0 {
     773           83 :         "success"
     774           64 :     } else if n_res > 0 {
     775            0 :         "partial"
     776           64 :     } else if n_empty > 0 {
     777           54 :         "empty"
     778              :     } else {
     779           10 :         "failure"
     780              :     };
     781          147 :     finish(st, tenant, id, status, Some((q_details, tq_details))).await;
     782          146 : }
     783              : 
     784              : /// The @context the creating request was expanded with (6.3.5), resolved
     785              : /// within the Snapshot's Tenant (5.5.10); the core context for a Snapshot
     786              : /// stored without one, or one whose context no longer loads.
     787          149 : async fn snapshot_context(
     788          149 :     st: &AppState,
     789          149 :     tenant: &TenantId,
     790          149 :     meta: &Value,
     791          149 : ) -> std::sync::Arc<antares_jsonld::Context> {
     792          149 :     match meta.get("__context").filter(|v| !v.is_null()) {
     793          149 :         Some(v) => st
     794          149 :             .loader
     795          149 :             .resolve_quiet_for(tenant, v)
     796          149 :             .await
     797          149 :             .unwrap_or_else(|_| st.loader.core()),
     798            0 :         None => st.loader.core(),
     799              :     }
     800          149 : }
     801              : 
     802              : /// One 5.2.23 Query executed per the DISTRIBUTED query behaviour
     803              : /// (5.16.1.4 -> 5.7.2.4): local content plus every matching Context
     804              : /// Source; results are copied into the snapshot's synthetic tenant. Every
     805              : /// page is retrieved ("all pages are to be retrieved completely") within
     806              : /// `budget` documents — beyond it nothing is copied (5.5.6).
     807          131 : async fn run_query(
     808          131 :     st: &AppState,
     809          131 :     tenant: &TenantId,
     810          131 :     synth: &TenantId,
     811          131 :     q: &Value,
     812          131 :     ctx: &antares_jsonld::Context,
     813          131 :     budget: usize,
     814          131 : ) -> Result<usize, NgsiError> {
     815          131 :     let qo = q
     816          131 :         .as_object()
     817          131 :         .ok_or_else(|| bad("Query must be an object".into()))?;
     818          131 :     let mut vp: HashMap<String, String> = HashMap::new();
     819          131 :     crate::paging::query_doc_params(qo, false, &mut vp)?;
     820              :     // the caller's own `q`, held to the caller's caps before the paged
     821              :     // filter reads it with the narrowed ones (threat V23)
     822          131 :     if let Some(q) = vp.get("q") {
     823           12 :         antares_ql::parse_q(q)?;
     824          119 :     }
     825          393 :     for k in ["limit", "offset", "count"] {
     826          393 :         vp.remove(k);
     827          393 :     }
     828          131 :     let headers = HeaderMap::new();
     829          131 :     let mut warnings = Vec::new();
     830          131 :     let fed = if crate::federation::active(&vp) {
     831          131 :         crate::federation::fed_query(st, tenant, &headers, ctx, &vp, &mut warnings).await?
     832              :     } else {
     833            0 :         Vec::new()
     834              :     };
     835              :     // 5.16.1.4: "If the size of the respective results require pagination,
     836              :     // all pages are to be retrieved completely." They are retrieved one at a
     837              :     // time. A query wider than the budget is refused, and it has to be
     838              :     // refused without first holding its whole match set in memory to measure
     839              :     // it: the store's pre-LIMIT count is that measurement. The count is the
     840              :     // match set's only while every predicate reached the store — an
     841              :     // idPattern is applied after it (5.2.33), so with one present the
     842              :     // documents that survive are counted instead.
     843          131 :     let exact_total = !vp.contains_key("idPattern");
     844              :     // Federated candidates are merged after the store answers (5.7.2.4), so
     845              :     // that round takes no page and is the whole match set; only a local
     846              :     // query walks on.
     847          131 :     let mut fed = fed;
     848          131 :     let mut n = 0usize;
     849          131 :     let mut offset = 0usize;
     850              :     loop {
     851              :         // Only the first round asks the store to count: the pre-LIMIT total
     852              :         // makes it visit the whole match set, and the budget needs that once.
     853              :         // Later rounds carry the next-page hint in the same member, which is
     854              :         // not a count and is not read as one.
     855          137 :         let counted = offset == 0;
     856          137 :         if counted {
     857          131 :             vp.insert("count".into(), "true".into());
     858          131 :         } else {
     859            6 :             vp.remove("count");
     860            6 :         }
     861          137 :         let batch = crate::entities::filter_entities_paged(
     862          137 :             st,
     863          137 :             tenant,
     864          137 :             &vp,
     865          137 :             ctx,
     866          137 :             std::mem::take(&mut fed),
     867          137 :             crate::entities::Pushdown {
     868          137 :                 page: Some((offset, st.max_limit)),
     869          137 :                 proj: None,
     870          137 :                 order_keys: None,
     871          137 :             },
     872          137 :             // a snapshot under a narrowing is refused at its gate
     873          137 :             None,
     874          137 :         )
     875          137 :         .await
     876          137 :         .map_err(|e| match e {
     877            0 :             ApiError::Ngsi(n) => n,
     878            0 :             other => opaque("query execution", &other),
     879            0 :         })?;
     880          137 :         if exact_total && counted {
     881          131 :             if let Some(total) = batch.total.filter(|_| batch.paged) {
     882          125 :                 if total > budget {
     883            4 :                     return Err(too_many(total, budget));
     884          121 :                 }
     885            6 :             }
     886            6 :         }
     887          133 :         let rows = batch.rows;
     888          133 :         let got = batch.docs.len();
     889          133 :         if n.saturating_add(got) > budget {
     890            0 :             return Err(too_many(n.saturating_add(got), budget));
     891          133 :         }
     892              :         // A write the store refuses fails the query. What this returns is
     893              :         // what the caller adds to `copied`, and `copied` decides both the
     894              :         // budget left for the next query and whether the synthetic tenant is
     895              :         // materialized — an empty snapshot has to read as empty rather than
     896              :         // as a tenant that never existed. Counting a copy that never happened
     897              :         // costs the snapshot both, and 5.2.41 would publish the query as a
     898              :         // success besides.
     899          133 :         for doc in batch.docs {
     900          110 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
     901          110 :                 st.store
     902          110 :                     .create(synth, Kind::Entity, id, doc.clone())
     903          110 :                     .await?;
     904            0 :             }
     905              :         }
     906          131 :         n = n.saturating_add(got);
     907          131 :         match crate::entities::next_scan_offset(offset, rows, 0, batch.paged, st.max_limit) {
     908            6 :             Some(next) => offset = next,
     909          125 :             None => break,
     910              :         }
     911              :     }
     912          125 :     Ok(n)
     913          131 : }
     914              : 
     915              : /// One temporal Query (temporalQ mandatory) — ids via the 5.7.4.4 path,
     916              : /// full evolutions copied via the store, every page retrieved but never
     917              : /// more than `budget` evolutions (5.5.6).
     918           20 : async fn run_temporal_query(
     919           20 :     st: &AppState,
     920           20 :     tenant: &TenantId,
     921           20 :     synth: &TenantId,
     922           20 :     id: &str,
     923           20 :     q: &Value,
     924           20 :     ctx: &antares_jsonld::Context,
     925           20 :     budget: usize,
     926           20 : ) -> Result<usize, NgsiError> {
     927           20 :     let qo = q
     928           20 :         .as_object()
     929           20 :         .ok_or_else(|| bad("Query must be an object".into()))?;
     930           20 :     let mut vp: HashMap<String, String> = HashMap::new();
     931           20 :     crate::paging::query_doc_params(qo, true, &mut vp)?;
     932           60 :     for k in ["limit", "offset", "count"] {
     933           60 :         vp.remove(k);
     934           60 :     }
     935              :     // The copy holds what the query selects: its attrs and its window. A
     936              :     // whole Evolution would hand a snapshot reader history the query never
     937              :     // asked for.
     938           20 :     let tq = crate::temporalq::TemporalQ::from_params(&vp, true)?;
     939           20 :     let attrs: Option<Vec<String>> = vp
     940           20 :         .get("attrs")
     941           20 :         .map(|a| a.split(',').map(|t| ctx.expand_key(t.trim())).collect());
     942           20 :     let tf = antares_store::filter::TemporalFilter {
     943           20 :         attrs: attrs.as_deref(),
     944           20 :         range: tq.as_ref().map(|t| antares_store::filter::InstanceRange {
     945           20 :             timerel: &t.timerel,
     946           20 :             time_at: &t.time_at,
     947           20 :             end_time_at: t.end_time_at.as_deref(),
     948           20 :             timeproperty: &t.timeproperty,
     949           20 :         }),
     950           20 :         timeproperty: tq
     951           20 :             .as_ref()
     952           20 :             .map_or("observedAt", |t| t.timeproperty.as_str()),
     953           20 :         ..Default::default()
     954              :     };
     955           20 :     let mut headers = HeaderMap::new();
     956           20 :     if let Ok(v) = tenant.as_str().parse() {
     957           20 :         headers.insert("NGSILD-Tenant", v);
     958           20 :     }
     959              :     // The query expands its terms with `ctx`, passed below; a URL context
     960              :     // rides as the Link a request carries (6.3.5) as well, which a Context
     961              :     // Source the query reaches is handed.
     962           20 :     if let Some(url) = ctx.source.as_str() {
     963           18 :         let link = format!(
     964              :             "<{url}>; rel=\"http://www.w3.org/ns/json-ld#context\"; type=\"application/ld+json\""
     965              :         );
     966           18 :         if let Ok(v) = link.parse() {
     967           18 :             headers.insert("Link", v);
     968           18 :         }
     969            2 :     }
     970              :     // 5.16.1.4: "If the size of the respective results require pagination,
     971              :     // all pages are to be retrieved completely."
     972           20 :     let mut n = 0usize;
     973           20 :     let mut offset = 0usize;
     974              :     loop {
     975              :         // a snapshot deleted (or evicted) mid-fill stops the paging; what is
     976              :         // already copied is reaped by the caller
     977           22 :         if snap_get(st, tenant, id).await.is_none() {
     978            0 :             break;
     979           22 :         }
     980           22 :         vp.insert("limit".into(), st.max_limit.to_string());
     981           22 :         vp.insert("offset".into(), offset.to_string());
     982              :         // The fill runs after the request that created the snapshot has
     983              :         // been answered, under a header map that is not the client's, so
     984              :         // the narrowing the creating subject was given is not in scope here
     985              :         // — a snapshot under a policy is P5's box (ADR-0020: a `Filter` on
     986              :         // a fill is a `Deny`, and the fill records whose it is).
     987              :         // The merged documents come back beside the response: an Evolution
     988              :         // this broker holds is copied from the store below, whole, and one a
     989              :         // registered Context Source holds exists only here.
     990           22 :         let mut merged: Vec<Value> = Vec::new();
     991           22 :         let resp = crate::temporal::query_temporal_collected(
     992           22 :             st,
     993           22 :             &vp,
     994           22 :             &headers,
     995           22 :             &crate::policy::Filter::default(),
     996           22 :             Some(&mut merged),
     997           22 :             Some(std::sync::Arc::new(ctx.clone())),
     998           22 :         )
     999           22 :         .await
    1000           22 :         .map_err(|e| match e {
    1001            0 :             ApiError::Ngsi(n) => n,
    1002            0 :             other => opaque("temporal query execution", &other),
    1003            0 :         })?;
    1004           22 :         let bytes = axum::body::to_bytes(resp.into_body(), st.max_answer_bytes)
    1005           22 :             .await
    1006           22 :             .map_err(|_| NgsiError::TooManyResults(crate::bounds::ANSWER_TOO_LARGE.into()))?;
    1007           18 :         let arr: Vec<Value> = serde_json::from_slice::<Value>(&bytes)
    1008           18 :             .ok()
    1009           18 :             .and_then(|v| v.as_array().cloned())
    1010           18 :             .unwrap_or_default();
    1011           18 :         let got = arr.len();
    1012           18 :         if n.saturating_add(got) > budget {
    1013            0 :             return Err(too_many(n.saturating_add(got), budget));
    1014           18 :         }
    1015           22 :         for d in arr {
    1016           22 :             let Some(eid) = d.get("id").and_then(Value::as_str) else {
    1017            0 :                 continue;
    1018              :             };
    1019              :             // `None` is an Entity with no Temporal Evolution to copy; an Err
    1020              :             // is the store refusing the read, which is not the same thing and
    1021              :             // is not this snapshot's to swallow.
    1022              :             //
    1023              :             // The copy REPLACES: a snapshot carrying both kinds of query runs
    1024              :             // its Entity queries first, and copying an Entity into the
    1025              :             // synthetic tenant fires the 5.6.11 auto-record hook, which leaves
    1026              :             // a Temporal Evolution holding only the instant of the copy. An
    1027              :             // insert-if-absent would find that stub and keep it, so the
    1028              :             // snapshot would answer temporal reads with one instance of a
    1029              :             // history the source has many of — and report the query a success.
    1030           22 :             let held = st
    1031           22 :                 .temporal
    1032           22 :                 .get_temporal(tenant, eid, &tf)
    1033           22 :                 .await?
    1034           22 :                 .map(|doc| selected(doc, attrs.as_deref(), tq.as_ref()));
    1035              :             // Nothing local means the Evolution came from a registered
    1036              :             // Context Source (5.7.4.4). The query's merged answer is every
    1037              :             // instance of it this broker was given, so it is what the
    1038              :             // snapshot keeps — a local copy is the source's whole history,
    1039              :             // a remote one is as much of it as the fan-out returned.
    1040           22 :             let doc = match held {
    1041           20 :                 Some(doc) => Some(doc),
    1042            2 :                 None => merged
    1043            2 :                     .iter()
    1044            4 :                     .find(|m| m.get("id").and_then(Value::as_str) == Some(eid))
    1045            2 :                     .cloned(),
    1046              :             };
    1047           22 :             if let Some(doc) = doc {
    1048           22 :                 st.temporal.upsert(synth, eid, doc).await?;
    1049           22 :                 n = n.saturating_add(1);
    1050            0 :             }
    1051              :         }
    1052           18 :         if got < st.max_limit {
    1053           16 :             break;
    1054            2 :         }
    1055            2 :         offset = offset.saturating_add(st.max_limit);
    1056              :     }
    1057           16 :     Ok(n)
    1058           20 : }
    1059              : 
    1060              : /// An Evolution cut to the Attributes `attrs` names and the instances in
    1061              : /// `tq`'s window; meta members stay. The store's window prunes too, but only
    1062              : /// as a narrowing a backend may skip.
    1063           20 : fn selected(
    1064           20 :     mut doc: Value,
    1065           20 :     attrs: Option<&[String]>,
    1066           20 :     tq: Option<&crate::temporalq::TemporalQ>,
    1067           20 : ) -> Value {
    1068           20 :     if let Some(o) = doc.as_object_mut() {
    1069          104 :         o.retain(|k, v| {
    1070          104 :             if antares_model::is_meta(k) {
    1071           80 :                 return true;
    1072           24 :             }
    1073           24 :             if attrs.is_some_and(|want| !want.iter().any(|w| w == k)) {
    1074            4 :                 return false;
    1075           20 :             }
    1076           20 :             if let (Some(tq), Some(insts)) = (tq, v.as_array_mut()) {
    1077           30 :                 insts.retain(|i| tq.instance_matches(i));
    1078            0 :             }
    1079           20 :             true
    1080          104 :         });
    1081            0 :     }
    1082           20 :     doc
    1083           20 : }
    1084              : 
    1085          171 : async fn finish(
    1086          171 :     st: &AppState,
    1087          171 :     tenant: &TenantId,
    1088          171 :     id: &str,
    1089          171 :     status: &str,
    1090          171 :     details: Option<(Vec<Value>, Vec<Value>)>,
    1091          171 : ) {
    1092          171 :     let Some(mut meta) = snap_get(st, tenant, id).await else {
    1093            0 :         return;
    1094              :     };
    1095          171 :     if let Some(o) = meta.as_object_mut() {
    1096          171 :         o.insert("snapshotStatus".into(), Value::String(status.into()));
    1097          171 :         o.insert("modifiedAt".into(), Value::String(now_iso()));
    1098          171 :         if let Some((q, tq)) = details {
    1099          147 :             if !q.is_empty() {
    1100          131 :                 o.insert("snapshotQueriesDetails".into(), Value::Array(q));
    1101          131 :             }
    1102          147 :             if !tq.is_empty() {
    1103           20 :                 o.insert("snapshotTemporalQueriesDetails".into(), Value::Array(tq));
    1104          127 :             }
    1105           24 :         }
    1106            0 :     }
    1107          171 :     if let Some(meta) = snap_put(st, tenant, &meta, FINISH_MEMBERS).await {
    1108          171 :         send_notification(st, &meta).await;
    1109            0 :     }
    1110          168 : }
    1111              : 
    1112              : /// 5.16.6 / 5.3.4 SnapshotNotification (sent only when endpoint is set).
    1113          191 : async fn send_notification(st: &AppState, meta: &Value) {
    1114          191 :     let Some(uri) = meta.get("endpoint").and_then(Value::as_str) else {
    1115          177 :         return;
    1116              :     };
    1117           14 :     if st.egress.check_url(uri).await.is_err() {
    1118            0 :         return;
    1119           14 :     }
    1120           14 :     let mut body = json!({
    1121           14 :         "id": format!("urn:ngsi-ld:SnapshotNotification:{}", uuid::Uuid::new_v4()),
    1122           14 :         "type": "SnapshotNotification",
    1123           14 :         "notifiedAt": now_iso(),
    1124           14 :         "snapshotId": meta.get("id").cloned().unwrap_or_default(),
    1125           14 :         "snapshotStatus": meta.get("snapshotStatus").cloned().unwrap_or_default(),
    1126           14 :         "snapshotPriority": meta.get("snapshotPriority").cloned().unwrap_or_default(),
    1127           14 :         "expiresAt": meta.get("expiresAt").cloned().unwrap_or_default(),
    1128              :     });
    1129              :     // 5.3.4 Table 5.3.4-1 names the temporal list
    1130              :     // "temporalSnapshotQueriesDetails" (unlike 5.2.41's
    1131              :     // "snapshotTemporalQueriesDetails") — the notification datatype's own
    1132              :     // table governs the notification payload.
    1133           28 :     for (from, to) in [
    1134           14 :         ("snapshotQueriesDetails", "snapshotQueriesDetails"),
    1135           14 :         (
    1136           14 :             "snapshotTemporalQueriesDetails",
    1137           14 :             "temporalSnapshotQueriesDetails",
    1138           14 :         ),
    1139           14 :     ] {
    1140           28 :         if let (Some(v), Some(body)) = (meta.get(from), body.as_object_mut()) {
    1141           15 :             body.insert(to.into(), v.clone());
    1142           15 :         }
    1143              :     }
    1144           14 :     let mut req = st.http.post(uri).header("Content-Type", "application/json");
    1145           14 :     if let Some(ri) = meta.get("receiverInfo").and_then(Value::as_array) {
    1146           10 :         for kv in ri {
    1147           10 :             if let (Some(k), Some(v)) = (
    1148           10 :                 kv.get("key").and_then(Value::as_str),
    1149           10 :                 kv.get("value").and_then(Value::as_str),
    1150           10 :             ) {
    1151           10 :                 req = req.header(k, v);
    1152           10 :             }
    1153              :         }
    1154            4 :     }
    1155           14 :     let req = req.body(serde_json::to_vec(&body).unwrap_or_default());
    1156           14 :     let _ = antares_jsonld::io_deadline(req.send(), 8_000).await;
    1157          185 : }
    1158              : 
    1159              : // ---------- 5.16.7 Purge Snapshots (DELETE /snapshots, 6.36.3.2) ----------
    1160              : 
    1161           60 : pub async fn purge_snapshots(
    1162           60 :     State(st): State<AppState>,
    1163           60 :     CleanParams(params): CleanParams,
    1164           60 :     headers: HeaderMap,
    1165           60 : ) -> Response {
    1166           60 :     let go = async {
    1167           60 :         let tenant = tenant_from(&headers)?;
    1168           60 :         check_params(&params, &["q", "local"])?;
    1169           60 :         gate!(st, &tenant, &headers, "5.16.7").await?;
    1170              :         // 5.16.7.4: the query is mandatory and restricted to Snapshot members
    1171           58 :         let q = params
    1172           58 :             .get("q")
    1173           58 :             .ok_or_else(|| bad("purge requires a q over Snapshot members (5.16.7.4)".into()))?;
    1174           56 :         let ast = antares_ql::parse_q(q)?;
    1175              :         // 5.16.7.4: the query is "restricted to members of the Snapshot
    1176              :         // data type" (Tables 5.2.41-1/-2)
    1177              :         const MEMBERS: [&str; 15] = [
    1178              :             "id",
    1179              :             "type",
    1180              :             "snapshotQueries",
    1181              :             "snapshotTemporalQueries",
    1182              :             "snapshotLifetime",
    1183              :             "snapshotPriority",
    1184              :             "endpoint",
    1185              :             "receiverInfo",
    1186              :             "snapshotStatus",
    1187              :             "snapshotQueriesDetails",
    1188              :             "snapshotTemporalQueriesDetails",
    1189              :             "createdAt",
    1190              :             "modifiedAt",
    1191              :             "expiresAt",
    1192              :             "lastUsedAt",
    1193              :         ];
    1194           56 :         if let Some(alien) = ast
    1195           56 :             .attribute_paths()
    1196           56 :             .into_iter()
    1197           58 :             .find(|a| !MEMBERS.contains(a))
    1198              :         {
    1199           12 :             return Err(bad(format!(
    1200           12 :                 "purge q is restricted to Snapshot members (5.16.7.4): {alien:?}"
    1201           12 :             ))
    1202           12 :             .into());
    1203           44 :         }
    1204              :         // `endpoint` (its userinfo) and `receiverInfo` are the credentials
    1205              :         // the 5.16.6 notification is sent with, which every read answers
    1206              :         // masked (`present`). A purge matching on them answers 204 either
    1207              :         // way and shows by which snapshots are gone whether a guessed
    1208              :         // token matched, so a q may not name them.
    1209           44 :         if let Some(secret) = ast
    1210           44 :             .attribute_paths()
    1211           44 :             .into_iter()
    1212           46 :             .find(|a| ["endpoint", "receiverInfo"].contains(a))
    1213              :         {
    1214           12 :             return Err(bad(format!(
    1215           12 :                 "purge q may not filter on {secret:?}: it holds the notification \
    1216           12 :                  credentials (5.16.7.4)"
    1217           12 :             ))
    1218           12 :             .into());
    1219           32 :         }
    1220           32 :         let ctx = st.loader.core();
    1221              :         // a listing that fails purges nothing, and says so: a 204 here
    1222              :         // would report a purge that never ran
    1223           32 :         let victims: Vec<Value> = st
    1224           32 :             .store
    1225           32 :             .list(&tenant, Kind::Snapshot)
    1226           32 :             .await?
    1227           30 :             .into_iter()
    1228           30 :             .filter(|meta| is_snapshot(meta) && crate::registry::csf_matches(&ast, meta, &ctx))
    1229           30 :             .collect();
    1230           30 :         for meta in victims {
    1231            8 :             if let Some(id) = meta.get("id").and_then(Value::as_str) {
    1232            8 :                 snap_remove(&st, &tenant, id, &meta).await;
    1233            0 :             }
    1234              :         }
    1235           30 :         Ok::<_, ApiError>(no_content(&tenant))
    1236           60 :     };
    1237           60 :     go.await.unwrap_or_else(|e| e.into_response())
    1238           60 : }
    1239              : 
    1240              : /// 5.16.2.4 / 5.16.3.4 / 5.16.4.4 / 5.16.5.4: every /snapshots/{id} method
    1241              : /// opens the same way — the tenant it runs in, `local` (6.3.18) as the only
    1242              : /// parameter this resource takes, and an id that must be a valid URI before
    1243              : /// the store is touched at all.
    1244          442 : fn open_snapshot(
    1245          442 :     params: &HashMap<String, String>,
    1246          442 :     headers: &HeaderMap,
    1247          442 :     id: &str,
    1248          442 : ) -> ApiResult<TenantId> {
    1249          442 :     let tenant = tenant_from(headers)?;
    1250          442 :     check_params(params, &["local"])?;
    1251          442 :     antares_model::EntityId::new(id)
    1252          442 :         .map_err(|_| bad(format!("snapshot id is not a valid URI: {id:?}")))?;
    1253          402 :     Ok(tenant)
    1254          442 : }
    1255              : 
    1256              : // ---------- 5.16.3/5.16.4/5.16.5: /snapshots/{id} (6.37) ----------
    1257              : 
    1258          302 : pub async fn retrieve_snapshot(
    1259          302 :     State(st): State<AppState>,
    1260          302 :     Path(id): Path<String>,
    1261          302 :     CleanParams(params): CleanParams,
    1262          302 :     headers: HeaderMap,
    1263          302 : ) -> Response {
    1264          302 :     let go = async {
    1265          302 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1266          292 :         gate!(st, &tenant, &headers, "5.16.3", ids: &[&id]).await?;
    1267          292 :         let accept = parse_accept(&headers)?;
    1268          292 :         let meta = snap_get(&st, &tenant, &id)
    1269          292 :             .await
    1270          292 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1271          258 :         let ctx = st.loader.core();
    1272          258 :         Ok::<_, ApiError>(respond(
    1273          258 :             StatusCode::OK,
    1274          258 :             present(&meta),
    1275          258 :             &ctx,
    1276          258 :             accept,
    1277          258 :             &tenant,
    1278          258 :         ))
    1279          302 :     };
    1280          302 :     go.await.unwrap_or_else(|e| e.into_response())
    1281          302 : }
    1282              : 
    1283           54 : pub async fn update_snapshot(
    1284           54 :     State(st): State<AppState>,
    1285           54 :     Path(id): Path<String>,
    1286           54 :     CleanParams(params): CleanParams,
    1287           54 :     headers: HeaderMap,
    1288           54 :     body: Bytes,
    1289           54 : ) -> Response {
    1290           54 :     let go = async {
    1291           54 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1292           44 :         gate!(st, &tenant, &headers, "5.16.4", ids: &[&id]).await?;
    1293           44 :         let accept = parse_accept(&headers)?;
    1294              :         // 5.16.4.4 binds this operation to 5.5.4, hence to the 6.3.5 rules;
    1295              :         // a PATCH may name its body application/merge-patch+json (6.3.4)
    1296           44 :         let v = parse_body(&st, &headers, &body, BodyKind::MergePatch)
    1297           44 :             .await?
    1298              :             .value;
    1299           40 :         let mut frag = validate(&v, Mode::Update)?;
    1300           30 :         let mut meta = snap_get(&st, &tenant, &id)
    1301           30 :             .await
    1302           30 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1303           20 :         if let Some(o) = meta.as_object_mut() {
    1304           20 :             keep_receiver_info(&mut frag, o)?;
    1305              :             // 5.16.4.4 / 5.5.8 merge of the updatable members
    1306           72 :             for k in [
    1307           18 :                 "snapshotLifetime",
    1308           18 :                 "snapshotPriority",
    1309           18 :                 "endpoint",
    1310           18 :                 "receiverInfo",
    1311           18 :             ] {
    1312           72 :                 match frag.get(k) {
    1313           36 :                     None => {}
    1314           18 :                     Some(Value::Null) => {
    1315           18 :                         o.remove(k);
    1316           18 :                     }
    1317           18 :                     Some(v) => {
    1318           18 :                         o.insert(k.into(), v.clone());
    1319           18 :                     }
    1320              :                 }
    1321              :             }
    1322           18 :             if frag.contains_key("snapshotLifetime") {
    1323              :                 // "it is possible to indirectly update expiresAt" (5.2.41)
    1324            8 :                 o.insert("expiresAt".into(), Value::String(expires_at(o)?));
    1325           10 :             }
    1326              :             // a removed priority is the default one again (Table 5.2.41-1)
    1327           18 :             o.entry("snapshotPriority".to_owned())
    1328           18 :                 .or_insert(Value::Number(5.into()));
    1329           18 :             o.insert("modifiedAt".into(), Value::String(now_iso()));
    1330            0 :         }
    1331           18 :         let meta = snap_put(&st, &tenant, &meta, UPDATE_MEMBERS)
    1332           18 :             .await
    1333           18 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1334              :         // 5.16.6: notifications are also sent after any status update
    1335           18 :         let (st2, meta2) = (st.clone(), meta.clone());
    1336           18 :         crate::spawn(async move {
    1337           16 :             send_notification(&st2, &meta2).await;
    1338           13 :         });
    1339           18 :         let ctx = st.loader.core();
    1340           18 :         Ok::<_, ApiError>(respond(
    1341           18 :             StatusCode::OK,
    1342           18 :             present(&meta),
    1343           18 :             &ctx,
    1344           18 :             accept,
    1345           18 :             &tenant,
    1346           18 :         ))
    1347           54 :     };
    1348           54 :     go.await.unwrap_or_else(|e| e.into_response())
    1349           54 : }
    1350              : 
    1351           40 : pub async fn delete_snapshot(
    1352           40 :     State(st): State<AppState>,
    1353           40 :     Path(id): Path<String>,
    1354           40 :     CleanParams(params): CleanParams,
    1355           40 :     headers: HeaderMap,
    1356           40 : ) -> Response {
    1357           40 :     let go = async {
    1358           40 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1359           30 :         gate!(st, &tenant, &headers, "5.16.5", ids: &[&id]).await?;
    1360           30 :         let meta = snap_get(&st, &tenant, &id)
    1361           30 :             .await
    1362           30 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1363           22 :         snap_remove(&st, &tenant, &id, &meta).await;
    1364           22 :         Ok::<_, ApiError>(no_content(&tenant))
    1365           40 :     };
    1366           40 :     go.await.unwrap_or_else(|e| e.into_response())
    1367           40 : }
    1368              : 
    1369              : // ---------- 5.16.2 Clone Snapshot (POST /snapshots/{id}/clone, 6.38) ----------
    1370              : 
    1371           46 : pub async fn clone_snapshot(
    1372           46 :     State(st): State<AppState>,
    1373           46 :     Path(id): Path<String>,
    1374           46 :     CleanParams(params): CleanParams,
    1375           46 :     headers: HeaderMap,
    1376           46 :     body: Bytes,
    1377           46 : ) -> Response {
    1378           46 :     let go = async {
    1379           46 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1380           36 :         gate!(st, &tenant, &headers, "5.16.2", ids: &[&id]).await?;
    1381           34 :         let src = snap_get(&st, &tenant, &id)
    1382           34 :             .await
    1383           34 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1384              :         // 5.16.2.3 makes the clone body optional; a body that IS sent obeys
    1385              :         // the 6.3.5 @context rules
    1386           26 :         let v: Value = if body.is_empty() {
    1387           16 :             json!({})
    1388              :         } else {
    1389           10 :             parse_body(&st, &headers, &body, BodyKind::Standard)
    1390           10 :                 .await?
    1391              :                 .value
    1392              :         };
    1393           26 :         let mut o = validate(&v, Mode::Clone)?;
    1394              :         // the clone carries the source's (read-only) query lineage
    1395           48 :         for k in ["snapshotQueries", "snapshotTemporalQueries"] {
    1396           48 :             if let Some(qv) = src.get(k) {
    1397            8 :                 o.insert(k.into(), qv.clone());
    1398           40 :             }
    1399              :         }
    1400           24 :         let (new_id, meta) = new_meta(o, &tenant, &headers)?;
    1401           24 :         snap_insert(&st, &tenant, &new_id, &meta).await?;
    1402              :         // a clone is a new snapshot: 5.5.15 resource pressure applies to it
    1403              :         // exactly as it does to a create, so cloning cannot grow the
    1404              :         // registry past the cap
    1405           24 :         if let Err(e) = evict_over_cap(&st, &tenant, &new_id).await {
    1406            0 :             snap_remove(&st, &tenant, &new_id, &meta).await;
    1407            0 :             return Err(e.into());
    1408           24 :         }
    1409           24 :         let (st2, t2, sid, nid) = (st.clone(), tenant.clone(), id.clone(), new_id.clone());
    1410           24 :         crate::spawn(async move {
    1411           24 :             clone_fill(&st2, &t2, &sid, &nid).await;
    1412           24 :         });
    1413           24 :         Ok::<_, ApiError>(created(format!("/ngsi-ld/v1/snapshots/{new_id}"), &tenant))
    1414           46 :     };
    1415           46 :     go.await.unwrap_or_else(|e| e.into_response())
    1416           46 : }
    1417              : 
    1418              : /// 5.16.2.4 background copy: all Entity and Temporal data of the source.
    1419              : /// A source still `preparing` is waited for (its copy is not complete
    1420              : /// until its fill finishes), for as long as both snapshots exist; a source
    1421              : /// that ends in `failure` or disappears makes the clone a `failure`. The
    1422              : /// copy stops at the same ceiling a fill does (5.5.6).
    1423           24 : async fn clone_fill(st: &AppState, tenant: &TenantId, src_id: &str, new_id: &str) {
    1424           24 :     let src = loop {
    1425           48 :         let Some(src) = snap_get(st, tenant, src_id).await else {
    1426            0 :             finish(st, tenant, new_id, "failure", None).await;
    1427            0 :             return;
    1428              :         };
    1429           48 :         if src.get("snapshotStatus").and_then(Value::as_str) != Some("preparing") {
    1430           24 :             break src;
    1431           24 :         }
    1432           24 :         if snap_get(st, tenant, new_id).await.is_none() {
    1433            0 :             return;
    1434           24 :         }
    1435              :         // the browser's own timer on wasm32 (tokio time has no reactor there)
    1436              :         #[cfg(not(target_arch = "wasm32"))]
    1437           24 :         tokio::time::sleep(std::time::Duration::from_millis(CLONE_POLL_MS.into())).await;
    1438              :         #[cfg(target_arch = "wasm32")]
    1439              :         gloo_timers::future::TimeoutFuture::new(CLONE_POLL_MS).await;
    1440              :     };
    1441           24 :     let (Some(from), Some(to)) = (
    1442           24 :         synth_tenant(&src),
    1443           24 :         snap_get(st, tenant, new_id)
    1444           24 :             .await
    1445           24 :             .as_ref()
    1446           24 :             .and_then(synth_tenant),
    1447              :     ) else {
    1448            0 :         finish(st, tenant, new_id, "failure", None).await;
    1449            0 :         return;
    1450              :     };
    1451           24 :     let failed = src.get("snapshotStatus").and_then(Value::as_str) == Some("failure")
    1452           20 :         || copy_snapshot_data(st, &from, &to, fill_cap(st))
    1453           20 :             .await
    1454           20 :             .is_err();
    1455           24 :     if fill_cancelled(st, tenant, new_id, &to).await {
    1456            0 :         return;
    1457           24 :     }
    1458           24 :     finish(
    1459           24 :         st,
    1460           24 :         tenant,
    1461           24 :         new_id,
    1462           24 :         if failed { "failure" } else { "success" },
    1463           24 :         None,
    1464              :     )
    1465           24 :     .await;
    1466           24 : }
    1467              : 
    1468              : /// How often a clone looks whether its source finished preparing.
    1469              : const CLONE_POLL_MS: u32 = 50;
    1470              : 
    1471              : /// Copy every Entity (one `list_page` at a time) and every Temporal
    1472              : /// Evolution of `from` into `to`, at most `cap` documents of each. An
    1473              : /// Evolution REPLACES what `to` holds: copying an Entity fires the 5.6.11
    1474              : /// auto-record hook, which leaves a stub Evolution of one instance.
    1475           24 : async fn copy_snapshot_data(
    1476           24 :     st: &AppState,
    1477           24 :     from: &TenantId,
    1478           24 :     to: &TenantId,
    1479           24 :     cap: usize,
    1480           24 : ) -> Result<(), NgsiError> {
    1481           24 :     let mut n = 0usize;
    1482           24 :     let mut after: Option<String> = None;
    1483              :     loop {
    1484          424 :         let page = st
    1485          424 :             .store
    1486          424 :             .list_page(from, Kind::Entity, after.as_deref(), st.max_limit)
    1487          424 :             .await?;
    1488          424 :         let Some(last) = page
    1489          424 :             .last()
    1490          424 :             .and_then(|d| d.get("id"))
    1491          424 :             .and_then(Value::as_str)
    1492              :         else {
    1493            2 :             break;
    1494              :         };
    1495          422 :         after = Some(last.to_owned());
    1496          422 :         n = n.saturating_add(page.len());
    1497          422 :         if n > cap {
    1498            4 :             return Err(too_many(n, cap));
    1499          418 :         }
    1500          418 :         let full = page.len() >= st.max_limit;
    1501          426 :         for doc in page {
    1502          426 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
    1503          426 :                 st.store.create(to, Kind::Entity, id, doc.clone()).await?;
    1504            0 :             }
    1505              :         }
    1506          418 :         if !full {
    1507           18 :             break;
    1508          400 :         }
    1509              :     }
    1510           20 :     let docs = st.temporal.list(from).await?;
    1511           20 :     if docs.len() > cap {
    1512            0 :         return Err(too_many(docs.len(), cap));
    1513           20 :     }
    1514           20 :     for doc in docs {
    1515           18 :         if let Some(id) = doc.get("id").and_then(Value::as_str) {
    1516           18 :             st.temporal.upsert(to, id, doc.clone()).await?;
    1517            0 :         }
    1518              :     }
    1519           20 :     Ok(())
    1520           24 : }
    1521              : 
    1522              : // ---------- 6.3.22: NGSILD-Snapshot scoping middleware ----------
    1523              : 
    1524              : /// 6.3.22 / 5.5.15: resolve the NGSILD-Snapshot header to the snapshot's
    1525              : /// synthetic tenant, so every Core/Temporal handler serves the frozen copy;
    1526              : /// the header is echoed on the response (and the synthetic tenant never
    1527              : /// leaks into NGSILD-Tenant).
    1528        55050 : pub async fn snapshot_layer(
    1529        55050 :     State(st): State<AppState>,
    1530        55050 :     req: axum::extract::Request,
    1531        55050 :     next: axum::middleware::Next,
    1532        55050 : ) -> Response {
    1533              :     // 6.3.22 gives the header one value, and it decides whether the request
    1534              :     // is answered from a frozen copy or from live data: an ambiguous one
    1535              :     // would serve the request against a dataset nobody named.
    1536        55050 :     let sid = match single_header(req.headers(), "NGSILD-Snapshot") {
    1537        54948 :         Ok(None) => return next.run(req).await,
    1538           98 :         Ok(Some(sid)) => sid,
    1539            4 :         Err(e) => return e.into_response(),
    1540              :     };
    1541              :     // 6.3.22: "If the HTTP header NGSILD-Snapshot is present in the HTTP
    1542              :     // request, it shall also be present in HTTP response" — every exit,
    1543              :     // the unscoped and the error ones included, leaves through here
    1544           98 :     let mut resp = scoped(&st, &sid, req, next).await;
    1545           98 :     if let Ok(v) = sid.parse() {
    1546           98 :         resp.headers_mut().insert("NGSILD-Snapshot", v);
    1547           98 :     }
    1548           98 :     resp
    1549        55050 : }
    1550              : 
    1551           98 : async fn scoped(
    1552           98 :     st: &AppState,
    1553           98 :     sid: &str,
    1554           98 :     mut req: axum::extract::Request,
    1555           98 :     next: axum::middleware::Next,
    1556           98 : ) -> Response {
    1557              :     // the Snapshot API's own resources (6.36-6.38) are never
    1558              :     // snapshot-scoped — that is the /snapshots RESOURCE, not any path
    1559              :     // merely containing the word (an Attribute may be named "snapshots")
    1560           98 :     if req
    1561           98 :         .uri()
    1562           98 :         .path()
    1563           98 :         .trim_start_matches(API_ROOT)
    1564           98 :         .starts_with("/snapshots")
    1565              :     {
    1566            4 :         return next.run(req).await;
    1567           94 :     }
    1568           94 :     let tenant = match tenant_from(req.headers()) {
    1569           94 :         Ok(t) => t,
    1570            0 :         Err(e) => return e.into_response(),
    1571              :     };
    1572           94 :     let Some(meta) = snap_get(st, &tenant, sid).await else {
    1573           16 :         return ApiError::from(NgsiError::ResourceNotFound(format!(
    1574           16 :             "snapshot {sid} not found"
    1575           16 :         )))
    1576           16 :         .into_response();
    1577              :     };
    1578           78 :     let Some(synth) = synth_tenant(&meta) else {
    1579            0 :         return ApiError::from(NgsiError::InternalError("snapshot without tenant".into()))
    1580            0 :             .into_response();
    1581              :     };
    1582           78 :     snap_touch(st, &tenant, sid).await;
    1583           78 :     if let Ok(v) = synth.as_str().parse() {
    1584           78 :         req.headers_mut().insert("NGSILD-Tenant", v);
    1585           78 :     }
    1586           78 :     let mut resp = next.run(req).await;
    1587              :     // restore the caller's tenant view (the synthetic one is internal)
    1588           78 :     match tenant.as_str() {
    1589           78 :         "default" => {
    1590           68 :             resp.headers_mut().remove("NGSILD-Tenant");
    1591           68 :         }
    1592           10 :         t => {
    1593           10 :             if let Ok(v) = t.parse() {
    1594           10 :                 resp.headers_mut().insert("NGSILD-Tenant", v);
    1595           10 :             }
    1596              :         }
    1597              :     }
    1598           78 :     resp
    1599           98 : }
    1600              : 
    1601              : /// 5.5.6: the fill copies its results into a second (isolated) tenant, so an
    1602              : /// unbounded result set doubles the stored data set in one request. The
    1603              : /// implementation threshold — "up to each implementation" — is a hundred
    1604              : /// full pages per snapshot; beyond it the query reports TooManyResults.
    1605          181 : fn fill_cap(st: &AppState) -> usize {
    1606          181 :     st.max_limit.saturating_mul(100)
    1607          181 : }
    1608              : 
    1609              : /// 5.16.1.4 + 5.5.15: the copied data is reachable only through its snapshot
    1610              : /// document, so a fill whose snapshot was deleted (or evicted under resource
    1611              : /// pressure) must stop and free what it already copied.
    1612          332 : async fn fill_cancelled(st: &AppState, tenant: &TenantId, id: &str, synth: &TenantId) -> bool {
    1613          332 :     if snap_get(st, tenant, id).await.is_some() {
    1614          326 :         return false;
    1615            6 :     }
    1616            6 :     purge_synth(st, synth).await;
    1617            6 :     true
    1618          332 : }
    1619              : 
    1620              : /// 5.5.6: "When a query operation is producing so many results that can
    1621              : /// potentially exhaust client or server resources … implementations shall
    1622              : /// raise an error of type TooManyResults" — reported per query in the
    1623              : /// 5.2.42 ExecutionResultDetails of the snapshot.
    1624            8 : fn too_many(got: usize, budget: usize) -> NgsiError {
    1625            8 :     NgsiError::TooManyResults(format!(
    1626            8 :         "snapshot query yields {got} results, {budget} left of the snapshot ceiling"
    1627            8 :     ))
    1628            8 : }
    1629              : 
    1630              : #[cfg(test)]
    1631              : mod clause_5_5_6 {
    1632              :     use super::*;
    1633              : 
    1634              :     /// 5.5.6 InternalError: the client-visible `detail` — served in
    1635              :     /// snapshotQueriesDetails.problemDetails and copied into the 5.3.4
    1636              :     /// SnapshotNotification body — must be generic. The Debug/IO text of
    1637              :     /// the underlying failure belongs in the server log only.
    1638              :     #[test]
    1639            4 :     fn snapshot_fill_error_detail_is_generic() {
    1640            4 :         let inner = ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE);
    1641            4 :         let pd = crate::negotiate::problem_value(&opaque("query execution", &inner));
    1642            4 :         let detail = pd["detail"].as_str().unwrap_or_default();
    1643            4 :         assert_eq!(detail, "query execution failed");
    1644            4 :         assert!(
    1645            4 :             !detail.contains("Bare") && !detail.contains("Unsupported"),
    1646              :             "internal error text leaked into the client-visible detail: {detail}"
    1647              :         );
    1648            4 :     }
    1649              : }
    1650              : 
    1651              : #[cfg(test)]
    1652              : mod clause_5_16 {
    1653              :     use super::*;
    1654              :     use axum::body::Body;
    1655              :     use axum::http::Request;
    1656              :     use tower::ServiceExt;
    1657              : 
    1658           72 :     async fn state() -> AppState {
    1659           72 :         crate::wired_state("antares-snapshots").await
    1660           72 :     }
    1661              : 
    1662              :     /// 5.16 with RFC 9110 §9.2.1: reading an expired Snapshot must not
    1663              :     /// write. The meta row survives the read; the sweep is what frees it,
    1664              :     /// its reverse-index entry and the synthetic tenant's data.
    1665              :     #[tokio::test(flavor = "multi_thread")]
    1666            4 :     async fn an_expired_snapshot_is_refused_by_a_read_that_writes_nothing() {
    1667            4 :         let st = state().await;
    1668            4 :         let t = TenantId::default();
    1669            4 :         let id = "urn:ngsi-ld:Snapshot:stale";
    1670            4 :         let synth = "snap-default-stale";
    1671            4 :         st.store
    1672            4 :             .create(
    1673            4 :                 &t,
    1674            4 :                 Kind::Snapshot,
    1675            4 :                 id,
    1676            4 :                 json!({"id": id, "type": "Snapshot", "__tenant": synth,
    1677            4 :                        "expiresAt": "2000-01-01T00:00:00Z"}),
    1678            4 :             )
    1679            4 :             .await
    1680            4 :             .expect("seed");
    1681            4 :         let idx = TenantId::new_internal("snap-index").expect("index tenant");
    1682            4 :         st.store
    1683            4 :             .create(&idx, Kind::Snapshot, synth, json!({"id": synth}))
    1684            4 :             .await
    1685            4 :             .expect("seed index");
    1686              : 
    1687            4 :         assert!(snap_get(&st, &t, id).await.is_none(), "5.16: not served");
    1688            4 :         assert!(
    1689            4 :             st.store
    1690            4 :                 .get(&t, Kind::Snapshot, id)
    1691            4 :                 .await
    1692            4 :                 .expect("store")
    1693            4 :                 .is_some(),
    1694              :             "the read deleted the meta: a GET must be safe (RFC 9110 9.2.1)"
    1695              :         );
    1696              : 
    1697            4 :         assert_eq!(sweep_expired_snapshots(&st, &t).await, 1, "the sweep reaps");
    1698            4 :         assert!(
    1699            4 :             st.store
    1700            4 :                 .get(&t, Kind::Snapshot, id)
    1701            4 :                 .await
    1702            4 :                 .expect("store")
    1703            4 :                 .is_none(),
    1704              :             "the sweep left the meta behind"
    1705              :         );
    1706              :         // The teardown runs behind the sweep: the copy is dropped first and
    1707              :         // the entry that names it last, so a broker that stops in the middle
    1708              :         // leaves a pointer to data that is still there.
    1709            4 :         for _ in 0..100 {
    1710            7 :             if st
    1711            7 :                 .store
    1712            7 :                 .get(&idx, Kind::Snapshot, synth)
    1713            7 :                 .await
    1714            7 :                 .expect("store")
    1715            7 :                 .is_none()
    1716            4 :             {
    1717            4 :                 return;
    1718            4 :             }
    1719            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    1720            4 :         }
    1721            4 :         panic!("the sweep left the reverse-index entry behind");
    1722            4 :     }
    1723              : 
    1724              :     /// The sweep takes the expiry and nothing else: a live Snapshot and the
    1725              :     /// reverse-index markers that share its storage both survive.
    1726              :     #[tokio::test]
    1727            4 :     async fn the_sweep_keeps_a_live_snapshot_and_the_index_markers() {
    1728            4 :         let st = state().await;
    1729            4 :         let t = TenantId::default();
    1730            4 :         let live = "urn:ngsi-ld:Snapshot:live";
    1731            4 :         st.store
    1732            4 :             .create(
    1733            4 :                 &t,
    1734            4 :                 Kind::Snapshot,
    1735            4 :                 live,
    1736            4 :                 json!({"id": live, "type": "Snapshot",
    1737            4 :                        "expiresAt": "2999-01-01T00:00:00Z"}),
    1738            4 :             )
    1739            4 :             .await
    1740            4 :             .expect("seed");
    1741              :         // a marker doc: same Kind, not a Snapshot, and long past any expiry
    1742            4 :         st.store
    1743            4 :             .create(
    1744            4 :                 &t,
    1745            4 :                 Kind::Snapshot,
    1746            4 :                 "snap-default-marker",
    1747            4 :                 json!({"id": "snap-default-marker", "expiresAt": "2000-01-01T00:00:00Z"}),
    1748            4 :             )
    1749            4 :             .await
    1750            4 :             .expect("seed marker");
    1751              : 
    1752            4 :         assert_eq!(sweep_expired_snapshots(&st, &t).await, 0);
    1753            4 :         assert!(snap_get(&st, &t, live).await.is_some(), "live one survives");
    1754            4 :         assert!(
    1755            4 :             st.store
    1756            4 :                 .get(&t, Kind::Snapshot, "snap-default-marker")
    1757            4 :                 .await
    1758            4 :                 .expect("store")
    1759            4 :                 .is_some(),
    1760            4 :             "the sweep took a reverse-index marker for a Snapshot"
    1761            4 :         );
    1762            4 :     }
    1763              : 
    1764              :     /// One request through the full router — the 6.3.22 middleware included.
    1765          617 :     async fn send(
    1766          617 :         st: &AppState,
    1767          617 :         method: &str,
    1768          617 :         path: &str,
    1769          617 :         body: Option<(&str, String)>,
    1770          617 :         extra: &[(&str, &str)],
    1771          617 :     ) -> (StatusCode, HeaderMap, Value) {
    1772          617 :         let mut b = Request::builder().method(method).uri(path);
    1773          617 :         for (k, v) in extra {
    1774           24 :             b = b.header(*k, *v);
    1775           24 :         }
    1776          617 :         let req = match body {
    1777          524 :             Some((ct, payload)) => b
    1778          524 :                 .header("Content-Type", ct)
    1779          524 :                 .header("Content-Length", payload.len())
    1780          524 :                 .body(Body::from(payload)),
    1781           93 :             None => b.body(Body::empty()),
    1782              :         }
    1783          617 :         .expect("request");
    1784          617 :         let res = crate::router(st.clone())
    1785          617 :             .oneshot(req)
    1786          617 :             .await
    1787          617 :             .expect("response");
    1788          617 :         let status = res.status();
    1789          617 :         let headers = res.headers().clone();
    1790          617 :         let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
    1791          617 :             .await
    1792          617 :             .expect("body");
    1793          617 :         let body = if bytes.is_empty() {
    1794          496 :             Value::Null
    1795              :         } else {
    1796          121 :             serde_json::from_slice(&bytes).unwrap_or(Value::Null)
    1797              :         };
    1798          617 :         (status, headers, body)
    1799          617 :     }
    1800              : 
    1801              :     /// 5.16.4.4 modifies the Snapshot "as mandated by clause 5.5.8": a
    1802              :     /// member whose value is an NGSI-LD Null (JSON null or
    1803              :     /// "urn:ngsi-ld:null") is removed, and 6.3.4 lets a PATCH name its body
    1804              :     /// application/merge-patch+json. The type checks refused the null with
    1805              :     /// 400, the literal "urn:ngsi-ld:null" was stored as the endpoint, and a
    1806              :     /// merge-patch body was 415.
    1807              :     #[tokio::test(flavor = "multi_thread")]
    1808            4 :     async fn clause_5_16_4_4_an_ngsi_ld_null_removes_a_member() {
    1809            4 :         let st = state().await;
    1810            4 :         let snap = json!({"type": "Snapshot", "endpoint": "http://127.0.0.1:9/n",
    1811            4 :             "snapshotPriority": 3, "snapshotLifetime": "PT1H",
    1812            4 :             "receiverInfo": [{"key": "X-A", "value": "1"}],
    1813            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1814            4 :         let (status, headers, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &snap).await;
    1815            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1816            4 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    1817            4 :         let patch = json!({"endpoint": null, "receiverInfo": "urn:ngsi-ld:null",
    1818            4 :             "snapshotPriority": null, "snapshotLifetime": "urn:ngsi-ld:null"});
    1819            4 :         let (status, _, body) = send(
    1820            4 :             &st,
    1821            4 :             "PATCH",
    1822            4 :             &loc,
    1823            4 :             Some(("application/merge-patch+json", patch.to_string())),
    1824            4 :             &[],
    1825              :         )
    1826            4 :         .await;
    1827            4 :         assert_eq!(status, StatusCode::OK, "{body}");
    1828           12 :         for k in ["endpoint", "receiverInfo", "snapshotLifetime"] {
    1829           12 :             assert!(body.get(k).is_none(), "{k} not removed: {body}");
    1830              :         }
    1831            4 :         assert_eq!(body["snapshotPriority"], 5, "the default priority: {body}");
    1832              :         // a null where no update removes it stays a wrong type
    1833            4 :         let mut bad = snap.clone();
    1834            4 :         bad["endpoint"] = Value::Null;
    1835            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &bad).await;
    1836            4 :         assert_eq!(status, StatusCode::BAD_REQUEST, "{b}");
    1837            4 :     }
    1838              : 
    1839              :     /// 5.16.6 notifies by HTTP POST: an endpoint written `http:user:pass@host`
    1840              :     /// (no `//`) is refused on create and on update, without the credential
    1841              :     /// in the answer; stored, its userinfo would be neither masked nor sealed
    1842              :     /// and reqwest would still send it.
    1843              :     #[tokio::test]
    1844            4 :     async fn an_endpoint_without_the_double_slash_is_refused_unechoed() {
    1845            4 :         let st = state().await;
    1846            4 :         let uri = "http:hook:pa55word@localhost:1/n";
    1847            4 :         let snap = json!({"type": "Snapshot", "endpoint": uri,
    1848            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1849            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &snap).await;
    1850            4 :         assert_eq!(status, StatusCode::BAD_REQUEST, "{b}");
    1851            4 :         assert!(
    1852            4 :             !b.to_string().contains("pa55word"),
    1853              :             "the credential was echoed"
    1854              :         );
    1855            4 :         let ok = json!({"type": "Snapshot",
    1856            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1857            4 :         let (status, headers, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &ok).await;
    1858            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1859            4 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    1860            4 :         let (status, _, b) = send(
    1861            4 :             &st,
    1862            4 :             "PATCH",
    1863            4 :             &loc,
    1864            4 :             Some(("application/json", json!({"endpoint": uri}).to_string())),
    1865            4 :             &[],
    1866              :         )
    1867            4 :         .await;
    1868            4 :         assert_eq!(status, StatusCode::BAD_REQUEST, "{b}");
    1869            4 :         assert!(
    1870            4 :             !b.to_string().contains("pa55word"),
    1871            4 :             "the credential was echoed"
    1872            4 :         );
    1873            4 :     }
    1874              : 
    1875          472 :     async fn post_json(st: &AppState, path: &str, doc: &Value) -> (StatusCode, HeaderMap, Value) {
    1876          472 :         send(
    1877          472 :             st,
    1878          472 :             "POST",
    1879          472 :             path,
    1880          472 :             Some(("application/json", doc.to_string())),
    1881          472 :             &[],
    1882          472 :         )
    1883          472 :         .await
    1884          472 :     }
    1885              : 
    1886           72 :     fn header<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
    1887           72 :         h.get(name).and_then(|v| v.to_str().ok())
    1888           72 :     }
    1889              : 
    1890              :     /// Create a snapshot over every Vehicle and wait for the background fill.
    1891           24 :     async fn snapshot_over_vehicles(st: &AppState) -> String {
    1892           24 :         let doc = json!({"type": "Snapshot",
    1893           24 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1894           24 :         let (status, headers, body) = post_json(st, "/ngsi-ld/v1/snapshots", &doc).await;
    1895           24 :         assert_eq!(status, StatusCode::CREATED, "{body}");
    1896           24 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    1897           24 :         for _ in 0..200 {
    1898           45 :             let (_, _, b) = send(st, "GET", &loc, None, &[]).await;
    1899           45 :             if b["snapshotStatus"] != "preparing" {
    1900           24 :                 return b["id"].as_str().expect("snapshot id").to_owned();
    1901           21 :             }
    1902           21 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    1903              :         }
    1904            0 :         panic!("snapshot never left preparing");
    1905           24 :     }
    1906              : 
    1907           95 :     async fn stored(st: &AppState, id: &str) -> Value {
    1908           95 :         st.store
    1909           95 :             .get(&TenantId::default(), Kind::Snapshot, id)
    1910           95 :             .await
    1911           95 :             .ok()
    1912           95 :             .flatten()
    1913           95 :             .expect("snapshot document")
    1914           95 :     }
    1915              : 
    1916              :     /// 6.3.22: the Snapshot API's own resources are never snapshot-scoped —
    1917              :     /// but that exemption is the /snapshots RESOURCE, not any path in which
    1918              :     /// the word occurs. An Entity attribute named "snapshots" stays scoped
    1919              :     /// to the frozen copy, so the live entity is untouched.
    1920              :     #[tokio::test(flavor = "multi_thread")]
    1921            4 :     async fn clause_6_3_22_scope_guard_matches_only_the_snapshot_resource() {
    1922            4 :         let st = state().await;
    1923            4 :         let ent = json!({"id": "urn:ngsi-ld:Vehicle:g1", "type": "Vehicle",
    1924            4 :             "snapshots": {"type": "Property", "value": 3}});
    1925            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/entities", &ent).await;
    1926            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1927            4 :         let sid = snapshot_over_vehicles(&st).await;
    1928              : 
    1929            4 :         let (status, headers, b) = send(
    1930            4 :             &st,
    1931            4 :             "DELETE",
    1932            4 :             "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:g1/attrs/snapshots",
    1933            4 :             None,
    1934            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1935              :         )
    1936            4 :         .await;
    1937            4 :         assert_eq!(status, StatusCode::NO_CONTENT, "{b}");
    1938            4 :         assert_eq!(header(&headers, "NGSILD-Snapshot"), Some(sid.as_str()));
    1939              : 
    1940            4 :         let (status, _, live) = send(
    1941            4 :             &st,
    1942            4 :             "GET",
    1943            4 :             "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:g1",
    1944            4 :             None,
    1945            4 :             &[],
    1946              :         )
    1947            4 :         .await;
    1948            4 :         assert_eq!(status, StatusCode::OK, "{live}");
    1949            4 :         assert!(
    1950            4 :             live.get("snapshots").is_some(),
    1951              :             "a snapshot-scoped request deleted the LIVE attribute: {live}"
    1952              :         );
    1953            4 :         let (_, _, frozen) = send(
    1954            4 :             &st,
    1955            4 :             "GET",
    1956            4 :             "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:g1",
    1957            4 :             None,
    1958            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1959              :         )
    1960            4 :         .await;
    1961            4 :         assert!(
    1962            4 :             frozen.get("snapshots").is_none(),
    1963            4 :             "the frozen copy still carries the deleted attribute: {frozen}"
    1964            4 :         );
    1965            4 :     }
    1966              : 
    1967              :     /// 6.3.22: "If the HTTP header NGSILD-Snapshot is present in the HTTP
    1968              :     /// request, it shall also be present in HTTP response" — on the
    1969              :     /// unscoped Snapshot resources and on the error exits as well.
    1970              :     #[tokio::test(flavor = "multi_thread")]
    1971            4 :     async fn clause_6_3_22_header_is_echoed_on_every_exit() {
    1972            4 :         let st = state().await;
    1973            4 :         let sid = snapshot_over_vehicles(&st).await;
    1974            4 :         let (status, headers, b) = send(
    1975            4 :             &st,
    1976            4 :             "GET",
    1977            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    1978            4 :             None,
    1979            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1980              :         )
    1981            4 :         .await;
    1982            4 :         assert_eq!(status, StatusCode::OK, "{b}");
    1983            4 :         assert_eq!(header(&headers, "NGSILD-Snapshot"), Some(sid.as_str()));
    1984              : 
    1985            4 :         let unknown = "urn:ngsi-ld:Snapshot:nope";
    1986            4 :         let (status, headers, b) = send(
    1987            4 :             &st,
    1988            4 :             "GET",
    1989            4 :             "/ngsi-ld/v1/entities?type=Vehicle",
    1990            4 :             None,
    1991            4 :             &[("NGSILD-Snapshot", unknown)],
    1992              :         )
    1993            4 :         .await;
    1994            4 :         assert_eq!(status, StatusCode::NOT_FOUND, "{b}");
    1995            4 :         assert_eq!(header(&headers, "NGSILD-Snapshot"), Some(unknown));
    1996            4 :         assert!(
    1997            4 :             !b.to_string().contains("snap-"),
    1998            4 :             "the synthetic tenant leaked to the client: {b}"
    1999            4 :         );
    2000            4 :     }
    2001              : 
    2002              :     /// 5.16.1.4: "If the NGSI-LD endpoint already knows about this Snapshot,
    2003              :     /// as there is an existing Snapshot whose id (URI) is equivalent, an
    2004              :     /// error of type AlreadyExists shall be raised" — and the rejected
    2005              :     /// create must not have replaced the existing snapshot.
    2006              :     #[tokio::test(flavor = "multi_thread")]
    2007            4 :     async fn clause_5_16_1_4_duplicate_id_is_already_exists() {
    2008            4 :         let st = state().await;
    2009            4 :         let mut doc = json!({"id": "urn:ngsi-ld:Snapshot:dup", "type": "Snapshot",
    2010            4 :             "snapshotPriority": 3,
    2011            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    2012            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &doc).await;
    2013            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    2014            4 :         doc["snapshotPriority"] = json!(9);
    2015            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &doc).await;
    2016            4 :         assert_eq!(status, StatusCode::CONFLICT, "{b}");
    2017            4 :         assert!(
    2018            4 :             b["type"]
    2019            4 :                 .as_str()
    2020            4 :                 .unwrap_or_default()
    2021            4 :                 .ends_with("AlreadyExists"),
    2022              :             "{b}"
    2023              :         );
    2024            4 :         let (_, _, got) = send(
    2025            4 :             &st,
    2026            4 :             "GET",
    2027            4 :             "/ngsi-ld/v1/snapshots/urn:ngsi-ld:Snapshot:dup",
    2028            4 :             None,
    2029            4 :             &[],
    2030              :         )
    2031            4 :         .await;
    2032            4 :         assert_eq!(
    2033            4 :             got["snapshotPriority"], 3,
    2034              :             "the rejected create overwrote the existing snapshot: {got}"
    2035              :         );
    2036            4 :         assert!(
    2037            4 :             got.get("__tenant").is_none(),
    2038            4 :             "internal member served to the client: {got}"
    2039            4 :         );
    2040            4 :     }
    2041              : 
    2042              :     /// 6.3.5 (5.16.4.4 → 5.5.4): snapshot bodies obey the @context rules —
    2043              :     /// application/json with a body @context is BadRequestData,
    2044              :     /// application/ld+json without one is BadRequestData, any other media
    2045              :     /// type is a bare 415, and @context is never stored in the snapshot.
    2046              :     #[tokio::test(flavor = "multi_thread")]
    2047            4 :     async fn clause_6_3_5_snapshot_bodies_follow_the_context_rules() {
    2048            4 :         let st = state().await;
    2049            4 :         let core = "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld";
    2050            4 :         let plain = json!({"type": "Snapshot",
    2051            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    2052            4 :         let mut with_ctx = plain.clone();
    2053            4 :         with_ctx["@context"] = json!(core);
    2054              : 
    2055            8 :         for (ct, doc) in [
    2056            4 :             ("application/json", &with_ctx),
    2057            4 :             ("application/ld+json", &plain),
    2058            4 :         ] {
    2059            8 :             let (status, _, b) = send(
    2060            8 :                 &st,
    2061            8 :                 "POST",
    2062            8 :                 "/ngsi-ld/v1/snapshots",
    2063            8 :                 Some((ct, doc.to_string())),
    2064            8 :                 &[],
    2065              :             )
    2066            8 :             .await;
    2067            8 :             assert_eq!(status, StatusCode::BAD_REQUEST, "{ct}: {b}");
    2068              :         }
    2069            4 :         let (status, _, _) = send(
    2070            4 :             &st,
    2071            4 :             "POST",
    2072            4 :             "/ngsi-ld/v1/snapshots",
    2073            4 :             Some(("text/plain", plain.to_string())),
    2074            4 :             &[],
    2075              :         )
    2076            4 :         .await;
    2077            4 :         assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
    2078              : 
    2079            4 :         let (status, headers, b) = send(
    2080            4 :             &st,
    2081            4 :             "POST",
    2082            4 :             "/ngsi-ld/v1/snapshots",
    2083            4 :             Some(("application/ld+json", with_ctx.to_string())),
    2084            4 :             &[],
    2085              :         )
    2086            4 :         .await;
    2087            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    2088            4 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    2089            4 :         let (_, _, got) = send(&st, "GET", &loc, None, &[]).await;
    2090            4 :         assert!(
    2091            4 :             got.get("@context").is_none(),
    2092              :             "@context stored as a snapshot member: {got}"
    2093              :         );
    2094            4 :         let patch = json!({"snapshotPriority": 7, "@context": core});
    2095            4 :         let (status, _, b) = send(
    2096            4 :             &st,
    2097            4 :             "PATCH",
    2098            4 :             &loc,
    2099            4 :             Some(("application/json", patch.to_string())),
    2100            4 :             &[],
    2101              :         )
    2102            4 :         .await;
    2103            4 :         assert_eq!(status, StatusCode::BAD_REQUEST, "{b}");
    2104            4 :     }
    2105              : 
    2106              :     /// 5.5.6: "When a query operation is producing so many results that can
    2107              :     /// potentially exhaust client or server resources … implementations
    2108              :     /// shall raise an error of type TooManyResults." A fill copies its
    2109              :     /// results, so the ceiling is enforced before anything is copied.
    2110              :     /// 5.5.6 and a temporal fill page larger than the broker buffers
    2111              :     /// for one request is TooManyResults, not held whole in memory.
    2112              :     #[tokio::test(flavor = "multi_thread")]
    2113            4 :     async fn clause_5_5_6_fill_stops_at_the_answer_byte_bound() {
    2114            4 :         let mut st = state().await;
    2115            4 :         let ent = json!({"id": "urn:ngsi-ld:Vehicle:big", "type": "Vehicle",
    2116            4 :             "speed": {"type": "Property", "value": 80}});
    2117            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/entities", &ent).await;
    2118            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    2119            4 :         st.max_answer_bytes = 16;
    2120            4 :         let doc = json!({"type": "Snapshot",
    2121            4 :             "snapshotTemporalQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}],
    2122            4 :                 "temporalQ": {"timerel": "after", "timeAt": "2000-01-01T00:00:00Z",
    2123            4 :                     "timeproperty": "createdAt"}}]});
    2124            4 :         let (status, headers, body) = post_json(&st, "/ngsi-ld/v1/snapshots", &doc).await;
    2125            4 :         assert_eq!(status, StatusCode::CREATED, "{body}");
    2126            4 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    2127            4 :         let mut ready = Value::Null;
    2128            4 :         for _ in 0..200 {
    2129            8 :             let (_, _, b) = send(&st, "GET", &loc, None, &[]).await;
    2130            8 :             if b["snapshotStatus"] != "preparing" {
    2131            4 :                 ready = b;
    2132            4 :                 break;
    2133            4 :             }
    2134            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    2135              :         }
    2136            4 :         assert_eq!(ready["snapshotStatus"], "failure", "{ready}");
    2137            4 :         let detail = ready["snapshotTemporalQueriesDetails"][0]["problemDetails"].clone();
    2138            4 :         assert!(
    2139            4 :             detail["type"]
    2140            4 :                 .as_str()
    2141            4 :                 .unwrap_or_default()
    2142            4 :                 .ends_with("TooManyResults"),
    2143              :             "{ready}"
    2144              :         );
    2145            4 :         assert_eq!(detail["detail"], crate::bounds::ANSWER_TOO_LARGE, "{ready}");
    2146            4 :     }
    2147              : 
    2148              :     #[tokio::test(flavor = "multi_thread")]
    2149            4 :     async fn clause_5_5_6_fill_stops_at_the_result_ceiling() {
    2150            4 :         let mut st = state().await;
    2151            4 :         st.max_limit = 1;
    2152            4 :         st.default_limit = 1;
    2153          404 :         for i in 0..=fill_cap(&st) {
    2154          404 :             let ent = json!({"id": format!("urn:ngsi-ld:Vehicle:c{i}"), "type": "Vehicle"});
    2155          404 :             let (status, _, b) = post_json(&st, "/ngsi-ld/v1/entities", &ent).await;
    2156          404 :             assert_eq!(status, StatusCode::CREATED, "{b}");
    2157              :         }
    2158            4 :         let sid = snapshot_over_vehicles(&st).await;
    2159            4 :         let ready = stored(&st, &sid).await;
    2160            4 :         assert_eq!(ready["snapshotStatus"], "failure", "{ready}");
    2161            4 :         let detail = &ready["snapshotQueriesDetails"][0];
    2162            4 :         assert_eq!(detail["resultStatus"], "failure", "{ready}");
    2163            4 :         assert!(
    2164            4 :             detail["problemDetails"]["type"]
    2165            4 :                 .as_str()
    2166            4 :                 .unwrap_or_default()
    2167            4 :                 .ends_with("TooManyResults"),
    2168              :             "{detail}"
    2169              :         );
    2170            4 :         let (status, _, list) = send(
    2171            4 :             &st,
    2172            4 :             "GET",
    2173            4 :             "/ngsi-ld/v1/entities?type=Vehicle&limit=1",
    2174            4 :             None,
    2175            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    2176              :         )
    2177            4 :         .await;
    2178            4 :         assert_eq!(status, StatusCode::OK, "{list}");
    2179            4 :         assert_eq!(
    2180            4 :             list,
    2181            4 :             json!([]),
    2182            4 :             "the oversized result set was copied into the snapshot anyway"
    2183            4 :         );
    2184            4 :     }
    2185              : 
    2186              :     /// 5.16.1.4 + 5.5.15: the filled copy is reachable only through its
    2187              :     /// snapshot document, so a fill whose snapshot has been deleted (or
    2188              :     /// evicted) stops and frees what it already copied.
    2189              :     #[tokio::test(flavor = "multi_thread")]
    2190            4 :     async fn clause_5_16_1_4_fill_stops_and_reaps_when_its_snapshot_is_gone() {
    2191            4 :         let st = state().await;
    2192            4 :         let tenant = TenantId::default();
    2193            4 :         let orphan = TenantId::new_internal("snap-reap-test").expect("tenant");
    2194            4 :         let _ = st
    2195            4 :             .store
    2196            4 :             .create(
    2197            4 :                 &orphan,
    2198            4 :                 Kind::Entity,
    2199            4 :                 "urn:ngsi-ld:Vehicle:r1",
    2200            4 :                 json!({"id": "urn:ngsi-ld:Vehicle:r1"}),
    2201            4 :             )
    2202            4 :             .await;
    2203            4 :         assert!(
    2204            4 :             fill_cancelled(&st, &tenant, "urn:ngsi-ld:Snapshot:gone", &orphan).await,
    2205              :             "a fill kept running after its snapshot was deleted"
    2206              :         );
    2207            4 :         assert!(
    2208            4 :             st.store
    2209            4 :                 .list(&orphan, Kind::Entity)
    2210            4 :                 .await
    2211            4 :                 .unwrap_or_default()
    2212            4 :                 .is_empty(),
    2213              :             "the orphaned copy outlived its snapshot"
    2214              :         );
    2215              : 
    2216            4 :         let sid = snapshot_over_vehicles(&st).await;
    2217            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    2218            4 :         let _ = st
    2219            4 :             .store
    2220            4 :             .create(
    2221            4 :                 &synth,
    2222            4 :                 Kind::Entity,
    2223            4 :                 "urn:ngsi-ld:Vehicle:r2",
    2224            4 :                 json!({"id": "urn:ngsi-ld:Vehicle:r2"}),
    2225            4 :             )
    2226            4 :             .await;
    2227            4 :         assert!(
    2228            4 :             !fill_cancelled(&st, &tenant, &sid, &synth).await,
    2229              :             "a live snapshot must not cancel its own fill"
    2230              :         );
    2231            4 :         assert_eq!(
    2232            4 :             st.store
    2233            4 :                 .list(&synth, Kind::Entity)
    2234            4 :                 .await
    2235            4 :                 .unwrap_or_default()
    2236            4 :                 .len(),
    2237            4 :             1,
    2238            4 :             "a live snapshot's copy was reaped"
    2239            4 :         );
    2240            4 :     }
    2241              : 
    2242              :     /// 5.16.5.4 + 5.5.15: deleting a snapshot frees the whole isolated copy,
    2243              :     /// including the subscriptions created through the 6.3.22 header — no
    2244              :     /// client can reach them afterwards, so nothing may keep firing.
    2245              :     #[tokio::test(flavor = "multi_thread")]
    2246            4 :     async fn clause_5_16_5_delete_frees_the_whole_snapshot_copy() {
    2247            4 :         let st = state().await;
    2248            4 :         let sid = snapshot_over_vehicles(&st).await;
    2249            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    2250            4 :         let sub = json!({"type": "Subscription", "entities": [{"type": "Vehicle"}],
    2251            4 :             "notification": {"endpoint": {"uri": "http://127.0.0.1:9/none"}}});
    2252            4 :         let (status, _, b) = send(
    2253            4 :             &st,
    2254            4 :             "POST",
    2255            4 :             "/ngsi-ld/v1/subscriptions",
    2256            4 :             Some(("application/json", sub.to_string())),
    2257            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    2258              :         )
    2259            4 :         .await;
    2260            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    2261            4 :         assert!(
    2262            4 :             !st.store
    2263            4 :                 .list(&synth, Kind::Subscription)
    2264            4 :                 .await
    2265            4 :                 .unwrap_or_default()
    2266            4 :                 .is_empty(),
    2267              :             "the scoped subscription did not land in the snapshot copy"
    2268              :         );
    2269              : 
    2270            4 :         let (status, _, _) = send(
    2271            4 :             &st,
    2272            4 :             "DELETE",
    2273            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    2274            4 :             None,
    2275            4 :             &[],
    2276              :         )
    2277            4 :         .await;
    2278            4 :         assert_eq!(status, StatusCode::NO_CONTENT);
    2279            4 :         for _ in 0..100 {
    2280            7 :             let left = st
    2281            7 :                 .store
    2282            7 :                 .list(&synth, Kind::Subscription)
    2283            7 :                 .await
    2284            7 :                 .unwrap_or_default()
    2285            7 :                 .len();
    2286            7 :             if left == 0 {
    2287            4 :                 return;
    2288            4 :             }
    2289            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    2290            4 :         }
    2291            4 :         panic!("a snapshot-scoped subscription outlived its snapshot");
    2292            4 :     }
    2293              : 
    2294              :     /// 5.2.41 Table 5.2.41-1: the snapshot id "cannot be later modified in
    2295              :     /// update operations" — whatever JSON type the client sends it as.
    2296              :     #[test]
    2297            4 :     fn clause_5_16_4_4_id_cannot_be_modified() {
    2298            4 :         assert!(validate(&json!({"id": "urn:ngsi-ld:Snapshot:1"}), Mode::Update).is_err());
    2299            4 :         assert!(
    2300            4 :             validate(&json!({"id": 5}), Mode::Update).is_err(),
    2301              :             "a non-string id escaped the 5.2.41 restriction"
    2302              :         );
    2303            4 :         assert!(validate(&json!({"snapshotPriority": 7}), Mode::Update).is_ok());
    2304            4 :     }
    2305              : 
    2306              :     /// 5.16.1.4: expiresAt is set "taking into account the snapshotLifetime
    2307              :     /// requested, but applying the configured limit of the NGSI-LD system";
    2308              :     /// a malformed duration is BadRequestData, and a snapshot past its
    2309              :     /// expiresAt is gone together with its copy.
    2310              :     #[tokio::test(flavor = "multi_thread")]
    2311            4 :     async fn clause_5_16_1_4_lifetime_is_clamped_and_expiry_removes_the_snapshot() {
    2312            4 :         let mut o = Map::new();
    2313            4 :         o.insert("snapshotLifetime".into(), json!("P10Y"));
    2314            4 :         let exp = chrono::DateTime::parse_from_rfc3339(&expires_at(&o).expect("expiresAt"))
    2315            4 :             .expect("rfc3339")
    2316            4 :             .with_timezone(&chrono::Utc);
    2317            4 :         let limit = chrono::Utc::now() + chrono::Duration::seconds(MAX_LIFETIME_SECS);
    2318            4 :         assert!(exp <= limit, "the configured limit was not applied: {exp}");
    2319            4 :         assert!(
    2320            4 :             exp > chrono::Utc::now() + chrono::Duration::days(6),
    2321              :             "the suggested lifetime was ignored: {exp}"
    2322              :         );
    2323            4 :         assert!(validate(
    2324            4 :             &json!({"type": "Snapshot", "snapshotLifetime": "tomorrow",
    2325            4 :                 "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]}),
    2326            4 :             Mode::Create
    2327            4 :         )
    2328            4 :         .is_err());
    2329              : 
    2330            4 :         let st = state().await;
    2331            4 :         let sid = snapshot_over_vehicles(&st).await;
    2332            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    2333            4 :         let _ = st
    2334            4 :             .store
    2335            4 :             .create(
    2336            4 :                 &synth,
    2337            4 :                 Kind::Entity,
    2338            4 :                 "urn:ngsi-ld:Vehicle:e1",
    2339            4 :                 json!({"id": "urn:ngsi-ld:Vehicle:e1"}),
    2340            4 :             )
    2341            4 :             .await;
    2342            4 :         let _ = st
    2343            4 :             .store
    2344            4 :             .mutate(&TenantId::default(), Kind::Snapshot, &sid, |d| {
    2345            4 :                 if let Some(o) = d.as_object_mut() {
    2346            4 :                     o.insert("expiresAt".into(), json!("2000-01-01T00:00:00.000Z"));
    2347            4 :                 }
    2348            4 :                 Ok::<_, std::convert::Infallible>(())
    2349            4 :             })
    2350            4 :             .await;
    2351            4 :         let (status, _, b) = send(
    2352            4 :             &st,
    2353            4 :             "GET",
    2354            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    2355            4 :             None,
    2356            4 :             &[],
    2357              :         )
    2358            4 :         .await;
    2359            4 :         assert_eq!(status, StatusCode::NOT_FOUND, "an expired snapshot: {b}");
    2360            4 :         assert!(
    2361            4 :             !st.store
    2362            4 :                 .list(&synth, Kind::Entity)
    2363            4 :                 .await
    2364            4 :                 .unwrap_or_default()
    2365            4 :                 .is_empty(),
    2366              :             "the GET freed the copy: reading must not write"
    2367              :         );
    2368              :         // The sweep is what frees it, and the purge it starts is background.
    2369            4 :         assert_eq!(
    2370            4 :             sweep_expired_snapshots(&st, &TenantId::default()).await,
    2371              :             1,
    2372              :             "the sweep did not reap the expired snapshot"
    2373              :         );
    2374            4 :         for _ in 0..100 {
    2375            7 :             if st
    2376            7 :                 .store
    2377            7 :                 .list(&synth, Kind::Entity)
    2378            7 :                 .await
    2379            7 :                 .unwrap_or_default()
    2380            7 :                 .is_empty()
    2381            4 :             {
    2382            4 :                 return;
    2383            4 :             }
    2384            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    2385            4 :         }
    2386            4 :         panic!("the copy of an expired snapshot was not freed");
    2387            4 :     }
    2388              : 
    2389              :     const DEF: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
    2390              : 
    2391              :     /// A Snapshot document held directly, with its synthetic tenant.
    2392           20 :     async fn seeded_snapshot(st: &AppState, id: &str, status: &str) -> TenantId {
    2393           20 :         let synth = format!("snap-default-{}", id.rsplit(':').next().unwrap_or("x"));
    2394           20 :         st.store
    2395           20 :             .create(
    2396           20 :                 &TenantId::default(),
    2397           20 :                 Kind::Snapshot,
    2398           20 :                 id,
    2399           20 :                 json!({"id": id, "type": "Snapshot", "__tenant": synth,
    2400           20 :                        "snapshotStatus": status, "expiresAt": "2999-01-01T00:00:00Z"}),
    2401           20 :             )
    2402           20 :             .await
    2403           20 :             .expect("seed snapshot");
    2404           20 :         synth_tenant(&stored(st, id).await).expect("__tenant")
    2405           20 :     }
    2406              : 
    2407          416 :     async fn vehicle_in(st: &AppState, t: &TenantId, n: usize) {
    2408          416 :         let id = format!("urn:ngsi-ld:Vehicle:c{n}");
    2409          416 :         st.store
    2410          416 :             .create(
    2411          416 :                 t,
    2412          416 :                 Kind::Entity,
    2413          416 :                 &id,
    2414          416 :                 json!({"id": id, "type": format!("{DEF}Vehicle")}),
    2415          416 :             )
    2416          416 :             .await
    2417          416 :             .expect("seed entity");
    2418          416 :     }
    2419              : 
    2420              :     /// POST .../clone and the clone's id.
    2421           16 :     async fn clone_of(st: &AppState, sid: &str) -> String {
    2422           16 :         let (status, headers, body) = send(
    2423           16 :             st,
    2424           16 :             "POST",
    2425           16 :             &format!("/ngsi-ld/v1/snapshots/{sid}/clone"),
    2426           16 :             Some(("application/json", String::new())),
    2427           16 :             &[],
    2428              :         )
    2429           16 :         .await;
    2430           16 :         assert_eq!(status, StatusCode::CREATED, "{body}");
    2431           16 :         let loc = header(&headers, "Location").expect("Location");
    2432           16 :         loc.rsplit('/').next().expect("clone id").to_owned()
    2433           16 :     }
    2434              : 
    2435           20 :     async fn settled(st: &AppState, id: &str) -> Value {
    2436           20 :         for _ in 0..200 {
    2437           35 :             let meta = stored(st, id).await;
    2438           35 :             if meta["snapshotStatus"] != "preparing" {
    2439           20 :                 return meta;
    2440           15 :             }
    2441           15 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    2442              :         }
    2443            0 :         panic!("snapshot {id} never left preparing");
    2444           20 :     }
    2445              : 
    2446              :     /// 5.16.2.4: the clone holds the source's whole Temporal
    2447              :     /// Evolution, never the one-instance stub its own Entity copy recorded.
    2448              :     #[tokio::test(flavor = "multi_thread")]
    2449            4 :     async fn clause_5_16_2_4_clone_copies_every_temporal_instance() {
    2450            4 :         let st = state().await;
    2451            4 :         let sid = "urn:ngsi-ld:Snapshot:tsrc";
    2452            4 :         let from = seeded_snapshot(&st, sid, "success").await;
    2453            4 :         let eid = "urn:ngsi-ld:Vehicle:c0";
    2454            4 :         let insts: Vec<Value> = (1..=5)
    2455           20 :             .map(|d| {
    2456           20 :                 json!({"type": "Property", "value": d,
    2457           20 :                             "observedAt": format!("2026-01-0{d}T00:00:00Z")})
    2458           20 :             })
    2459            4 :             .collect();
    2460            4 :         st.temporal
    2461            4 :             .upsert(
    2462            4 :                 &from,
    2463            4 :                 eid,
    2464            4 :                 json!({"id": eid, "type": format!("{DEF}Vehicle"),
    2465            4 :                                         format!("{DEF}speed"): insts.clone()}),
    2466            4 :             )
    2467            4 :             .await
    2468            4 :             .expect("seed evolution");
    2469            4 :         vehicle_in(&st, &from, 0).await;
    2470            4 :         let cid = clone_of(&st, sid).await;
    2471            4 :         assert_eq!(settled(&st, &cid).await["snapshotStatus"], "success");
    2472            4 :         let to = synth_tenant(&stored(&st, &cid).await).expect("__tenant");
    2473            4 :         let doc = st
    2474            4 :             .temporal
    2475            4 :             .get(&to, eid)
    2476            4 :             .await
    2477            4 :             .expect("store")
    2478            4 :             .expect("evolution");
    2479            4 :         assert_eq!(
    2480            4 :             doc[format!("{DEF}speed")].as_array().map(Vec::len),
    2481              :             Some(5),
    2482              :             "the clone kept a stub: {doc}"
    2483              :         );
    2484              :         // the stub the 5.6.11 hook leaves when it records the Entity copy
    2485            4 :         let stubbed = TenantId::new_internal("snap-default-stubbed").expect("tenant");
    2486            4 :         st.temporal
    2487            4 :             .upsert(
    2488            4 :                 &stubbed,
    2489            4 :                 eid,
    2490            4 :                 json!({"id": eid, format!("{DEF}speed"): [insts.first().cloned().expect("an instance")]}),
    2491            4 :             )
    2492            4 :             .await
    2493            4 :             .expect("seed stub");
    2494            4 :         copy_snapshot_data(&st, &from, &stubbed, fill_cap(&st))
    2495            4 :             .await
    2496            4 :             .expect("copy");
    2497            4 :         let doc = st
    2498            4 :             .temporal
    2499            4 :             .get(&stubbed, eid)
    2500            4 :             .await
    2501            4 :             .expect("store")
    2502            4 :             .expect("evolution");
    2503            4 :         assert_eq!(
    2504            4 :             doc[format!("{DEF}speed")].as_array().map(Vec::len),
    2505            4 :             Some(5),
    2506            4 :             "the copy kept the hook's stub: {doc}"
    2507            4 :         );
    2508            4 :     }
    2509              : 
    2510              :     /// 5.16.2.4: a clone of a source still preparing waits for
    2511              :     /// the source's fill and copies what it ends with; a source that fails
    2512              :     /// fails the clone.
    2513              :     #[tokio::test(flavor = "multi_thread")]
    2514            4 :     async fn clause_5_16_2_4_clone_waits_for_a_preparing_source() {
    2515            4 :         let st = state().await;
    2516            4 :         let sid = "urn:ngsi-ld:Snapshot:psrc";
    2517            4 :         let from = seeded_snapshot(&st, sid, "preparing").await;
    2518            4 :         vehicle_in(&st, &from, 1).await;
    2519            4 :         let cid = clone_of(&st, sid).await;
    2520            4 :         tokio::time::sleep(std::time::Duration::from_millis(300)).await;
    2521            4 :         assert_eq!(stored(&st, &cid).await["snapshotStatus"], "preparing");
    2522            4 :         vehicle_in(&st, &from, 2).await;
    2523            4 :         let _ = st
    2524            4 :             .store
    2525            4 :             .mutate(&TenantId::default(), Kind::Snapshot, sid, |d| {
    2526            4 :                 d["snapshotStatus"] = json!("success");
    2527            4 :                 Ok::<_, std::convert::Infallible>(())
    2528            4 :             })
    2529            4 :             .await;
    2530            4 :         assert_eq!(settled(&st, &cid).await["snapshotStatus"], "success");
    2531            4 :         let to = synth_tenant(&stored(&st, &cid).await).expect("__tenant");
    2532            4 :         assert_eq!(
    2533            4 :             st.store.list(&to, Kind::Entity).await.expect("list").len(),
    2534              :             2
    2535              :         );
    2536              : 
    2537            4 :         let fid = "urn:ngsi-ld:Snapshot:fsrc";
    2538            4 :         seeded_snapshot(&st, fid, "failure").await;
    2539            4 :         let cid = clone_of(&st, fid).await;
    2540            4 :         assert_eq!(settled(&st, &cid).await["snapshotStatus"], "failure");
    2541            4 :     }
    2542              : 
    2543              :     /// 5.5.6: a clone stops at the fill ceiling of its own
    2544              :     /// broker, whatever the source was filled under.
    2545              :     #[tokio::test(flavor = "multi_thread")]
    2546            4 :     async fn clause_5_5_6_clone_stops_at_the_result_ceiling() {
    2547            4 :         let mut st = state().await;
    2548            4 :         st.max_limit = 1;
    2549            4 :         let sid = "urn:ngsi-ld:Snapshot:big";
    2550            4 :         let from = seeded_snapshot(&st, sid, "success").await;
    2551          404 :         for n in 0..=fill_cap(&st) {
    2552          404 :             vehicle_in(&st, &from, n).await;
    2553              :         }
    2554            4 :         let cid = clone_of(&st, sid).await;
    2555            4 :         assert_eq!(settled(&st, &cid).await["snapshotStatus"], "failure");
    2556            4 :     }
    2557              : 
    2558              :     /// 5.16.4.4: a fill finishing with a document it read
    2559              :     /// before an Update Snapshot keeps the update's expiresAt, and the
    2560              :     /// update keeps the fill's status.
    2561              :     #[tokio::test(flavor = "multi_thread")]
    2562            4 :     async fn clause_5_16_4_4_finish_and_update_keep_each_others_members() {
    2563            4 :         let st = state().await;
    2564            4 :         let t = TenantId::default();
    2565            4 :         let sid = "urn:ngsi-ld:Snapshot:race";
    2566            4 :         seeded_snapshot(&st, sid, "preparing").await;
    2567            4 :         let mut read_by_fill = snap_get(&st, &t, sid).await.expect("meta");
    2568            4 :         let (status, _, b) = send(
    2569            4 :             &st,
    2570            4 :             "PATCH",
    2571            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    2572            4 :             Some((
    2573            4 :                 "application/json",
    2574            4 :                 json!({"snapshotLifetime": "PT1H"}).to_string(),
    2575            4 :             )),
    2576            4 :             &[],
    2577              :         )
    2578            4 :         .await;
    2579            4 :         assert!(status.is_success(), "{status} {b}");
    2580            4 :         let updated = stored(&st, sid).await["expiresAt"].clone();
    2581            4 :         assert_ne!(updated, json!("2999-01-01T00:00:00Z"));
    2582            4 :         read_by_fill["snapshotStatus"] = json!("success");
    2583            4 :         snap_put(&st, &t, &read_by_fill, FINISH_MEMBERS).await;
    2584            4 :         let meta = stored(&st, sid).await;
    2585            4 :         assert_eq!(
    2586            4 :             meta["expiresAt"], updated,
    2587              :             "the fill wrote back a stale expiresAt"
    2588              :         );
    2589            4 :         assert_eq!(meta["snapshotStatus"], "success");
    2590            4 :     }
    2591              : 
    2592              :     /// 5.16.1.4: a temporal snapshot query's attrs and window
    2593              :     /// bound what the snapshot holds.
    2594              :     #[tokio::test(flavor = "multi_thread")]
    2595            4 :     async fn clause_5_16_1_4_temporal_copy_keeps_the_query_attrs_and_window() {
    2596            4 :         let st = state().await;
    2597            4 :         let eid = "urn:ngsi-ld:Vehicle:tq";
    2598           16 :         let inst = |v: i64, d: u8| {
    2599           16 :             json!({"type": "Property", "value": v,
    2600           16 :                                           "observedAt": format!("2026-01-0{d}T00:00:00Z")})
    2601           16 :         };
    2602            4 :         let ent = json!({"id": eid, "type": "Vehicle",
    2603            4 :             "a": [inst(1, 1), inst(2, 3), inst(3, 5)], "b": [inst(9, 3)]});
    2604            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/temporal/entities", &ent).await;
    2605            4 :         assert!(status.is_success(), "{status} {b}");
    2606            4 :         let doc = json!({"type": "Snapshot", "snapshotTemporalQueries": [{
    2607            4 :             "type": "Query", "entities": [{"type": "Vehicle"}], "attrs": ["a"],
    2608            4 :             "temporalQ": {"timerel": "between", "timeAt": "2026-01-02T00:00:00Z",
    2609            4 :                           "endTimeAt": "2026-01-04T00:00:00Z"}}]});
    2610            4 :         let (status, headers, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &doc).await;
    2611            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    2612            4 :         let sid = header(&headers, "Location")
    2613            4 :             .and_then(|l| l.rsplit('/').next())
    2614            4 :             .expect("id")
    2615            4 :             .to_owned();
    2616            4 :         assert_eq!(settled(&st, &sid).await["snapshotStatus"], "success");
    2617            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    2618            4 :         let held = st
    2619            4 :             .temporal
    2620            4 :             .get(&synth, eid)
    2621            4 :             .await
    2622            4 :             .expect("store")
    2623            4 :             .expect("copy");
    2624            4 :         assert!(
    2625            4 :             held.get(format!("{DEF}b")).is_none(),
    2626              :             "b is outside attrs: {held}"
    2627              :         );
    2628            4 :         assert_eq!(
    2629            4 :             held[format!("{DEF}a")].as_array().map(Vec::len),
    2630            4 :             Some(1),
    2631            4 :             "instances outside the window: {held}"
    2632            4 :         );
    2633            4 :     }
    2634              : }
        

Generated by: LCOV version 2.0-1